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,1491 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+ const acme = require('acme-client')
4
+ const selfsigned = require('selfsigned')
5
+
6
+ // Import test utilities
7
+ const {setupGlobalMocks, cleanupGlobalMocks} = require('./__mocks__/testHelpers')
8
+ const {createMockWebsiteConfig} = require('./__mocks__/testFactories')
9
+ const {mockCandy} = require('./__mocks__/globalCandy')
10
+
11
+ // Mock all dependencies
12
+ jest.mock('fs')
13
+ jest.mock('os')
14
+ jest.mock('acme-client')
15
+ jest.mock('selfsigned')
16
+
17
+ describe('SSL', () => {
18
+ let SSL
19
+ let mockConfig
20
+ let mockLog
21
+ let mockDNS
22
+
23
+ beforeEach(() => {
24
+ // Reset all mocks
25
+ jest.clearAllMocks()
26
+
27
+ // Set up global mocks
28
+ setupGlobalMocks()
29
+
30
+ // Mock the __ function to return formatted strings
31
+ global.__ = jest.fn((key, ...args) => {
32
+ // Simple string formatting for test purposes
33
+ let result = key
34
+ args.forEach((arg, index) => {
35
+ result = result.replace('%s', arg)
36
+ })
37
+ return result
38
+ })
39
+
40
+ // Get mock instances from global Candy
41
+ mockConfig = mockCandy.core('Config')
42
+ mockLog = mockCandy.server('Log').init('SSL')
43
+ mockDNS = mockCandy.server('DNS')
44
+
45
+ // Set up DNS mock methods
46
+ mockDNS.record = jest.fn()
47
+ mockDNS.delete = jest.fn()
48
+
49
+ // Mock os.homedir
50
+ os.homedir.mockReturnValue('/home/test')
51
+
52
+ // Mock fs methods
53
+ fs.existsSync.mockReturnValue(true)
54
+ fs.mkdirSync.mockImplementation(() => {})
55
+ fs.writeFileSync.mockImplementation(() => {})
56
+
57
+ // Import SSL module after mocks are set up
58
+ // Clear the require cache to get a fresh instance
59
+ delete require.cache[require.resolve('../../server/src/SSL.js')]
60
+ SSL = require('../../server/src/SSL.js')
61
+ })
62
+
63
+ afterEach(() => {
64
+ cleanupGlobalMocks()
65
+ })
66
+
67
+ describe('initialization', () => {
68
+ it('should initialize SSL module correctly', () => {
69
+ expect(SSL).toBeDefined()
70
+ expect(typeof SSL.check).toBe('function')
71
+ expect(typeof SSL.renew).toBe('function')
72
+ })
73
+ })
74
+
75
+ describe('certificate checking and renewal logic', () => {
76
+ describe('check method', () => {
77
+ it('should skip checking if already checking', async () => {
78
+ // Set up a website config to trigger SSL generation
79
+ mockConfig.config.websites = {
80
+ 'example.com': createMockWebsiteConfig('example.com')
81
+ }
82
+ mockConfig.config.ssl = null // Force self-signed generation
83
+
84
+ // Start first check (will set checking flag)
85
+ const checkPromise1 = SSL.check()
86
+ // Start second check immediately (should be skipped due to checking flag)
87
+ const checkPromise2 = SSL.check()
88
+
89
+ await Promise.all([checkPromise1, checkPromise2])
90
+
91
+ // Self-signed certificate should only be generated once
92
+ expect(selfsigned.generate).toHaveBeenCalledTimes(1)
93
+ })
94
+
95
+ describe('certificate expiration date validation', () => {
96
+ it('should validate certificate expiry dates correctly', async () => {
97
+ const mockWebsite = createMockWebsiteConfig('example.com')
98
+ mockWebsite.cert.ssl.expiry = Date.now() + 1000 * 60 * 60 * 24 * 60 // 60 days (valid)
99
+
100
+ mockConfig.config.websites = {
101
+ 'example.com': mockWebsite
102
+ }
103
+
104
+ await SSL.check()
105
+
106
+ // Should not trigger renewal for valid certificate
107
+ expect(acme.forge.createPrivateKey).not.toHaveBeenCalled()
108
+ })
109
+
110
+ it('should trigger renewal for certificates expiring within 30 days', async () => {
111
+ const mockWebsite = createMockWebsiteConfig('example.com')
112
+ mockWebsite.cert.ssl.expiry = Date.now() + 1000 * 60 * 60 * 24 * 15 // 15 days (needs renewal)
113
+
114
+ mockConfig.config.websites = {
115
+ 'example.com': mockWebsite
116
+ }
117
+
118
+ await SSL.check()
119
+
120
+ // Should trigger renewal for certificate expiring soon
121
+ expect(acme.forge.createPrivateKey).toHaveBeenCalled()
122
+ })
123
+
124
+ it('should trigger renewal for expired certificates', async () => {
125
+ const mockWebsite = createMockWebsiteConfig('example.com')
126
+ mockWebsite.cert.ssl.expiry = Date.now() - 1000 * 60 * 60 * 24 // Expired yesterday
127
+
128
+ mockConfig.config.websites = {
129
+ 'example.com': mockWebsite
130
+ }
131
+
132
+ await SSL.check()
133
+
134
+ // Should trigger renewal for expired certificate
135
+ expect(acme.forge.createPrivateKey).toHaveBeenCalled()
136
+ })
137
+ })
138
+
139
+ describe('certificate file existence checking', () => {
140
+ it('should trigger renewal when certificate configuration is missing', async () => {
141
+ const mockWebsite = createMockWebsiteConfig('example.com')
142
+ mockWebsite.cert.ssl = null // No SSL configuration
143
+
144
+ mockConfig.config.websites = {
145
+ 'example.com': mockWebsite
146
+ }
147
+
148
+ await SSL.check()
149
+
150
+ // Should trigger renewal when SSL configuration is missing
151
+ expect(acme.forge.createPrivateKey).toHaveBeenCalled()
152
+ })
153
+
154
+ it('should skip renewal when certificate files exist and are valid', async () => {
155
+ const mockWebsite = createMockWebsiteConfig('example.com')
156
+ mockWebsite.cert.ssl.expiry = Date.now() + 1000 * 60 * 60 * 24 * 60 // Valid expiry
157
+
158
+ mockConfig.config.websites = {
159
+ 'example.com': mockWebsite
160
+ }
161
+
162
+ // Mock file existence check to return true
163
+ fs.existsSync.mockReturnValue(true)
164
+
165
+ await SSL.check()
166
+
167
+ // Should not trigger renewal when files exist and certificate is valid
168
+ expect(acme.forge.createPrivateKey).not.toHaveBeenCalled()
169
+ })
170
+
171
+ it('should validate self-signed certificate file existence', async () => {
172
+ mockConfig.config.websites = {
173
+ 'example.com': createMockWebsiteConfig('example.com')
174
+ }
175
+ mockConfig.config.ssl = {
176
+ key: '/home/test/.candypack/cert/ssl/candypack.key',
177
+ cert: '/home/test/.candypack/cert/ssl/candypack.crt',
178
+ expiry: Date.now() + 86400000 // Valid
179
+ }
180
+
181
+ // Mock self-signed certificate files as missing
182
+ fs.existsSync.mockImplementation(path => {
183
+ if (path.includes('candypack.key') || path.includes('candypack.crt')) {
184
+ return false
185
+ }
186
+ return true
187
+ })
188
+
189
+ await SSL.check()
190
+
191
+ // Should regenerate self-signed certificate when files are missing
192
+ expect(selfsigned.generate).toHaveBeenCalled()
193
+ })
194
+ })
195
+
196
+ describe('automatic renewal triggers', () => {
197
+ it('should automatically trigger renewal for certificates near expiry', async () => {
198
+ const mockWebsite = createMockWebsiteConfig('example.com')
199
+ // Set expiry to exactly 29 days from now (within threshold)
200
+ mockWebsite.cert.ssl.expiry = Date.now() + 1000 * 60 * 60 * 24 * 29
201
+
202
+ mockConfig.config.websites = {
203
+ 'example.com': mockWebsite
204
+ }
205
+
206
+ await SSL.check()
207
+
208
+ // Should trigger renewal when less than 30 days remain
209
+ expect(acme.forge.createPrivateKey).toHaveBeenCalled()
210
+ })
211
+
212
+ it('should not trigger renewal for certificates with more than 30 days validity', async () => {
213
+ const mockWebsite = createMockWebsiteConfig('example.com')
214
+ // Set expiry to 31 days from now (just over threshold)
215
+ mockWebsite.cert.ssl.expiry = Date.now() + 1000 * 60 * 60 * 24 * 31
216
+
217
+ mockConfig.config.websites = {
218
+ 'example.com': mockWebsite
219
+ }
220
+
221
+ await SSL.check()
222
+
223
+ // Should not trigger renewal when certificate has more than 30 days
224
+ expect(acme.forge.createPrivateKey).not.toHaveBeenCalled()
225
+ })
226
+
227
+ it('should handle missing SSL configuration gracefully', async () => {
228
+ const mockWebsite = createMockWebsiteConfig('example.com')
229
+ mockWebsite.cert.ssl = null // No SSL config
230
+
231
+ mockConfig.config.websites = {
232
+ 'example.com': mockWebsite
233
+ }
234
+
235
+ await SSL.check()
236
+
237
+ // Should trigger renewal when SSL config is missing
238
+ expect(acme.forge.createPrivateKey).toHaveBeenCalled()
239
+ })
240
+ })
241
+
242
+ it('should skip checking if no websites configured', async () => {
243
+ mockConfig.config.websites = null
244
+
245
+ await SSL.check()
246
+
247
+ // Should not attempt to generate certificates for domains
248
+ expect(acme.forge.createPrivateKey).not.toHaveBeenCalled()
249
+ })
250
+
251
+ it('should generate self-signed certificate if missing', async () => {
252
+ mockConfig.config.websites = {
253
+ 'example.com': createMockWebsiteConfig('example.com')
254
+ }
255
+ mockConfig.config.ssl = null
256
+
257
+ // Mock directory doesn't exist to trigger creation
258
+ fs.existsSync.mockReturnValue(false)
259
+
260
+ await SSL.check()
261
+
262
+ expect(selfsigned.generate).toHaveBeenCalledWith([{name: 'commonName', value: 'CandyPack'}], {days: 365, keySize: 2048})
263
+ expect(fs.mkdirSync).toHaveBeenCalledWith('/home/test/.candypack/cert/ssl', {recursive: true})
264
+ expect(fs.writeFileSync).toHaveBeenCalledTimes(2)
265
+ })
266
+
267
+ it('should skip self-signed generation if valid certificate exists', async () => {
268
+ mockConfig.config.websites = {
269
+ 'example.com': createMockWebsiteConfig('example.com')
270
+ }
271
+ mockConfig.config.ssl = {
272
+ key: '/path/to/key',
273
+ cert: '/path/to/cert',
274
+ expiry: Date.now() + 86400000
275
+ }
276
+
277
+ await SSL.check()
278
+
279
+ expect(selfsigned.generate).not.toHaveBeenCalled()
280
+ })
281
+
282
+ it('should check SSL certificates for all domains', async () => {
283
+ const mockWebsite = createMockWebsiteConfig('example.com')
284
+ mockWebsite.cert = false // Skip SSL for this domain
285
+
286
+ const testWebsite = createMockWebsiteConfig('test.com')
287
+ testWebsite.cert.ssl = null // Force renewal for test.com
288
+
289
+ mockConfig.config.websites = {
290
+ 'example.com': mockWebsite,
291
+ 'test.com': testWebsite
292
+ }
293
+
294
+ await SSL.check()
295
+
296
+ // Should process test.com but skip example.com
297
+ expect(acme.forge.createPrivateKey).toHaveBeenCalled()
298
+ })
299
+
300
+ it('should renew certificates near expiry', async () => {
301
+ const mockWebsite = createMockWebsiteConfig('example.com')
302
+ mockWebsite.cert = {
303
+ ssl: {
304
+ key: '/path/to/key',
305
+ cert: '/path/to/cert',
306
+ expiry: Date.now() + 1000 * 60 * 60 * 24 * 15 // 15 days (less than 30 day threshold)
307
+ }
308
+ }
309
+
310
+ mockConfig.config.websites = {
311
+ 'example.com': mockWebsite
312
+ }
313
+
314
+ await SSL.check()
315
+
316
+ expect(acme.forge.createPrivateKey).toHaveBeenCalled()
317
+ expect(acme.Client).toHaveBeenCalled()
318
+ })
319
+
320
+ it('should skip renewal for valid certificates', async () => {
321
+ const mockWebsite = createMockWebsiteConfig('example.com')
322
+ mockWebsite.cert = {
323
+ ssl: {
324
+ key: '/path/to/key',
325
+ cert: '/path/to/cert',
326
+ expiry: Date.now() + 1000 * 60 * 60 * 24 * 60 // 60 days (more than 30 day threshold)
327
+ }
328
+ }
329
+
330
+ mockConfig.config.websites = {
331
+ 'example.com': mockWebsite
332
+ }
333
+
334
+ await SSL.check()
335
+
336
+ expect(acme.forge.createPrivateKey).not.toHaveBeenCalled()
337
+ })
338
+ })
339
+
340
+ describe('renew method', () => {
341
+ beforeEach(() => {
342
+ // Set up the API mock to return proper result format
343
+ const mockApi = mockCandy.server('Api')
344
+ mockApi.result = jest.fn((success, message) => ({success, data: message}))
345
+ })
346
+
347
+ it('should reject renewal for IP addresses', () => {
348
+ const result = SSL.renew('192.168.1.1')
349
+
350
+ expect(result.success).toBe(false)
351
+ expect(result.data).toContain('SSL renewal is not available for IP addresses')
352
+ })
353
+
354
+ it('should return error for non-existent domain', () => {
355
+ mockConfig.config.websites = {}
356
+
357
+ const result = SSL.renew('nonexistent.com')
358
+
359
+ expect(result.success).toBe(false)
360
+ expect(result.data).toBe('Domain nonexistent.com not found.')
361
+ })
362
+
363
+ it('should find domain by subdomain', () => {
364
+ const mockWebsite = createMockWebsiteConfig('example.com')
365
+ mockWebsite.subdomain = ['www', 'api']
366
+ mockConfig.config.websites = {
367
+ 'example.com': mockWebsite
368
+ }
369
+
370
+ const result = SSL.renew('www.example.com')
371
+
372
+ expect(result.success).toBe(true)
373
+ expect(result.data).toBe('SSL certificate for domain example.com renewed successfully.')
374
+ })
375
+
376
+ it('should successfully renew existing domain', () => {
377
+ mockConfig.config.websites = {
378
+ 'example.com': createMockWebsiteConfig('example.com')
379
+ }
380
+
381
+ const result = SSL.renew('example.com')
382
+
383
+ expect(result.success).toBe(true)
384
+ expect(result.data).toBe('SSL certificate for domain example.com renewed successfully.')
385
+ })
386
+ })
387
+ })
388
+
389
+ describe('ACME protocol integration and challenge handling', () => {
390
+ let mockClient
391
+
392
+ beforeEach(() => {
393
+ mockClient = {
394
+ auto: jest.fn().mockResolvedValue('mock-certificate')
395
+ }
396
+ acme.Client.mockImplementation(() => mockClient)
397
+ })
398
+
399
+ describe('ACME client initialization and account creation', () => {
400
+ it('should create ACME client with correct configuration', async () => {
401
+ const mockWebsite = createMockWebsiteConfig('example.com')
402
+ // Remove SSL cert to trigger renewal
403
+ mockWebsite.cert.ssl = null
404
+ mockConfig.config.websites = {
405
+ 'example.com': mockWebsite
406
+ }
407
+
408
+ await SSL.check()
409
+
410
+ expect(acme.forge.createPrivateKey).toHaveBeenCalled()
411
+ expect(acme.Client).toHaveBeenCalledWith({
412
+ directoryUrl: acme.directory.letsencrypt.production,
413
+ accountKey: 'mock-private-key'
414
+ })
415
+ })
416
+
417
+ it('should create private key for ACME account', async () => {
418
+ const mockWebsite = createMockWebsiteConfig('example.com')
419
+ mockWebsite.cert.ssl = null
420
+ mockConfig.config.websites = {
421
+ 'example.com': mockWebsite
422
+ }
423
+
424
+ await SSL.check()
425
+
426
+ expect(acme.forge.createPrivateKey).toHaveBeenCalledWith()
427
+ })
428
+
429
+ it("should use Let's Encrypt production directory URL", async () => {
430
+ const mockWebsite = createMockWebsiteConfig('example.com')
431
+ mockWebsite.cert.ssl = null
432
+ mockConfig.config.websites = {
433
+ 'example.com': mockWebsite
434
+ }
435
+
436
+ await SSL.check()
437
+
438
+ expect(acme.Client).toHaveBeenCalledWith({
439
+ directoryUrl: acme.directory.letsencrypt.production,
440
+ accountKey: 'mock-private-key'
441
+ })
442
+ })
443
+
444
+ it('should create CSR with domain and subdomains', async () => {
445
+ const mockWebsite = createMockWebsiteConfig('example.com')
446
+ mockWebsite.subdomain = ['www', 'api']
447
+ // Remove SSL cert to trigger renewal
448
+ mockWebsite.cert.ssl = null
449
+ mockConfig.config.websites = {
450
+ 'example.com': mockWebsite
451
+ }
452
+
453
+ await SSL.check()
454
+
455
+ expect(acme.forge.createCsr).toHaveBeenCalledWith({
456
+ commonName: 'example.com',
457
+ altNames: ['example.com', 'www.example.com', 'api.example.com']
458
+ })
459
+ })
460
+
461
+ it('should handle domains without subdomains in CSR', async () => {
462
+ const mockWebsite = createMockWebsiteConfig('example.com')
463
+ mockWebsite.subdomain = [] // No subdomains
464
+ mockWebsite.cert.ssl = null
465
+ mockConfig.config.websites = {
466
+ 'example.com': mockWebsite
467
+ }
468
+
469
+ await SSL.check()
470
+
471
+ expect(acme.forge.createCsr).toHaveBeenCalledWith({
472
+ commonName: 'example.com',
473
+ altNames: ['example.com']
474
+ })
475
+ })
476
+
477
+ it('should handle undefined subdomains in CSR', async () => {
478
+ const mockWebsite = createMockWebsiteConfig('example.com')
479
+ delete mockWebsite.subdomain // Undefined subdomains
480
+ mockWebsite.cert.ssl = null
481
+ mockConfig.config.websites = {
482
+ 'example.com': mockWebsite
483
+ }
484
+
485
+ await SSL.check()
486
+
487
+ expect(acme.forge.createCsr).toHaveBeenCalledWith({
488
+ commonName: 'example.com',
489
+ altNames: ['example.com']
490
+ })
491
+ })
492
+ })
493
+
494
+ describe('DNS-01 challenge creation and DNS record management', () => {
495
+ it('should create DNS challenge records with correct parameters', async () => {
496
+ const mockWebsite = createMockWebsiteConfig('example.com')
497
+ // Remove SSL cert to trigger renewal
498
+ mockWebsite.cert.ssl = null
499
+ mockConfig.config.websites = {
500
+ 'example.com': mockWebsite
501
+ }
502
+
503
+ // Mock the auto method to call challengeCreateFn
504
+ mockClient.auto.mockImplementation(async options => {
505
+ const authz = {identifier: {value: 'example.com'}}
506
+ const challenge = {type: 'dns-01'}
507
+ const keyAuthorization = 'mock-key-auth'
508
+
509
+ await options.challengeCreateFn(authz, challenge, keyAuthorization)
510
+ return 'mock-certificate'
511
+ })
512
+
513
+ await SSL.check()
514
+
515
+ expect(mockDNS.record).toHaveBeenCalledWith({
516
+ name: '_acme-challenge.example.com',
517
+ type: 'TXT',
518
+ value: 'mock-key-auth',
519
+ ttl: 100,
520
+ unique: true
521
+ })
522
+ })
523
+
524
+ it('should create DNS challenge records for subdomains', async () => {
525
+ const mockWebsite = createMockWebsiteConfig('example.com')
526
+ mockWebsite.subdomain = ['www']
527
+ mockWebsite.cert.ssl = null
528
+ mockConfig.config.websites = {
529
+ 'example.com': mockWebsite
530
+ }
531
+
532
+ // Mock the auto method to call challengeCreateFn for subdomain
533
+ mockClient.auto.mockImplementation(async options => {
534
+ const authz = {identifier: {value: 'www.example.com'}}
535
+ const challenge = {type: 'dns-01'}
536
+ const keyAuthorization = 'subdomain-key-auth'
537
+
538
+ await options.challengeCreateFn(authz, challenge, keyAuthorization)
539
+ return 'mock-certificate'
540
+ })
541
+
542
+ await SSL.check()
543
+
544
+ expect(mockDNS.record).toHaveBeenCalledWith({
545
+ name: '_acme-challenge.www.example.com',
546
+ type: 'TXT',
547
+ value: 'subdomain-key-auth',
548
+ ttl: 100,
549
+ unique: true
550
+ })
551
+ })
552
+
553
+ it('should handle non-DNS challenge types gracefully', async () => {
554
+ const mockWebsite = createMockWebsiteConfig('example.com')
555
+ mockWebsite.cert.ssl = null
556
+ mockConfig.config.websites = {
557
+ 'example.com': mockWebsite
558
+ }
559
+
560
+ // Mock the auto method to call challengeCreateFn with http-01 challenge
561
+ mockClient.auto.mockImplementation(async options => {
562
+ const authz = {identifier: {value: 'example.com'}}
563
+ const challenge = {type: 'http-01'}
564
+ const keyAuthorization = 'http-key-auth'
565
+
566
+ await options.challengeCreateFn(authz, challenge, keyAuthorization)
567
+ return 'mock-certificate'
568
+ })
569
+
570
+ await SSL.check()
571
+
572
+ // Should not create DNS record for non-DNS challenges
573
+ expect(mockDNS.record).not.toHaveBeenCalled()
574
+ })
575
+
576
+ it('should remove DNS challenge records after validation', async () => {
577
+ const mockWebsite = createMockWebsiteConfig('example.com')
578
+ mockWebsite.cert.ssl = null // Force renewal
579
+ mockConfig.config.websites = {
580
+ 'example.com': mockWebsite
581
+ }
582
+
583
+ // Mock the auto method to call challengeRemoveFn
584
+ mockClient.auto.mockImplementation(async options => {
585
+ const authz = {identifier: {value: 'example.com'}}
586
+ const challenge = {type: 'dns-01'}
587
+ const keyAuthorization = 'mock-key-auth'
588
+
589
+ await options.challengeRemoveFn(authz, challenge, keyAuthorization)
590
+ return 'mock-certificate'
591
+ })
592
+
593
+ await SSL.check()
594
+
595
+ expect(mockDNS.delete).toHaveBeenCalledWith({
596
+ name: '_acme-challenge.example.com',
597
+ type: 'TXT',
598
+ value: 'mock-key-auth'
599
+ })
600
+ })
601
+
602
+ it('should remove DNS challenge records for subdomains', async () => {
603
+ const mockWebsite = createMockWebsiteConfig('example.com')
604
+ mockWebsite.cert.ssl = null
605
+ mockConfig.config.websites = {
606
+ 'example.com': mockWebsite
607
+ }
608
+
609
+ // Mock the auto method to call challengeRemoveFn for subdomain
610
+ mockClient.auto.mockImplementation(async options => {
611
+ const authz = {identifier: {value: 'api.example.com'}}
612
+ const challenge = {type: 'dns-01'}
613
+ const keyAuthorization = 'api-key-auth'
614
+
615
+ await options.challengeRemoveFn(authz, challenge, keyAuthorization)
616
+ return 'mock-certificate'
617
+ })
618
+
619
+ await SSL.check()
620
+
621
+ expect(mockDNS.delete).toHaveBeenCalledWith({
622
+ name: '_acme-challenge.api.example.com',
623
+ type: 'TXT',
624
+ value: 'api-key-auth'
625
+ })
626
+ })
627
+
628
+ it('should handle non-DNS challenge removal gracefully', async () => {
629
+ const mockWebsite = createMockWebsiteConfig('example.com')
630
+ mockWebsite.cert.ssl = null
631
+ mockConfig.config.websites = {
632
+ 'example.com': mockWebsite
633
+ }
634
+
635
+ // Mock the auto method to call challengeRemoveFn with http-01 challenge
636
+ mockClient.auto.mockImplementation(async options => {
637
+ const authz = {identifier: {value: 'example.com'}}
638
+ const challenge = {type: 'http-01'}
639
+ const keyAuthorization = 'http-key-auth'
640
+
641
+ await options.challengeRemoveFn(authz, challenge, keyAuthorization)
642
+ return 'mock-certificate'
643
+ })
644
+
645
+ await SSL.check()
646
+
647
+ // Should not attempt to delete DNS record for non-DNS challenges
648
+ expect(mockDNS.delete).not.toHaveBeenCalled()
649
+ })
650
+
651
+ it('should handle challenge key authorization correctly', async () => {
652
+ const mockWebsite = createMockWebsiteConfig('example.com')
653
+ mockWebsite.cert.ssl = null
654
+ mockConfig.config.websites = {
655
+ 'example.com': mockWebsite
656
+ }
657
+
658
+ // Mock the auto method to call challengeKeyAuthorizationFn
659
+ mockClient.auto.mockImplementation(async options => {
660
+ const challenge = {type: 'dns-01'}
661
+ const keyAuthorization = 'mock-key-auth'
662
+
663
+ const result = await options.challengeKeyAuthorizationFn(challenge, keyAuthorization)
664
+ expect(result).toBe('mock-key-auth')
665
+ return 'mock-certificate'
666
+ })
667
+
668
+ await SSL.check()
669
+ })
670
+
671
+ it('should handle challenge timeout gracefully', async () => {
672
+ const mockWebsite = createMockWebsiteConfig('example.com')
673
+ mockWebsite.cert.ssl = null // Force renewal
674
+ mockConfig.config.websites = {
675
+ 'example.com': mockWebsite
676
+ }
677
+
678
+ // Mock the auto method to call challengeTimeoutFn
679
+ mockClient.auto.mockImplementation(async options => {
680
+ await options.challengeTimeoutFn()
681
+ return 'mock-certificate'
682
+ })
683
+
684
+ await SSL.check()
685
+
686
+ expect(mockClient.auto).toHaveBeenCalled()
687
+ })
688
+
689
+ it('should use dns-01 as challenge priority', async () => {
690
+ const mockWebsite = createMockWebsiteConfig('example.com')
691
+ mockWebsite.cert.ssl = null
692
+ mockConfig.config.websites = {
693
+ 'example.com': mockWebsite
694
+ }
695
+
696
+ await SSL.check()
697
+
698
+ expect(mockClient.auto).toHaveBeenCalledWith(
699
+ expect.objectContaining({
700
+ challengePriority: ['dns-01']
701
+ })
702
+ )
703
+ })
704
+ })
705
+
706
+ describe('certificate signing request (CSR) generation and processing', () => {
707
+ it('should generate CSR with correct parameters', async () => {
708
+ const mockWebsite = createMockWebsiteConfig('example.com')
709
+ mockWebsite.subdomain = [] // No subdomains for this test
710
+ mockWebsite.cert.ssl = null // Force renewal
711
+ mockConfig.config.websites = {
712
+ 'example.com': mockWebsite
713
+ }
714
+
715
+ await SSL.check()
716
+
717
+ expect(acme.forge.createCsr).toHaveBeenCalledWith({
718
+ commonName: 'example.com',
719
+ altNames: ['example.com']
720
+ })
721
+ })
722
+
723
+ it('should generate CSR with multiple domains including subdomains', async () => {
724
+ const mockWebsite = createMockWebsiteConfig('example.com')
725
+ mockWebsite.subdomain = ['www', 'api', 'mail']
726
+ mockWebsite.cert.ssl = null
727
+ mockConfig.config.websites = {
728
+ 'example.com': mockWebsite
729
+ }
730
+
731
+ await SSL.check()
732
+
733
+ expect(acme.forge.createCsr).toHaveBeenCalledWith({
734
+ commonName: 'example.com',
735
+ altNames: ['example.com', 'www.example.com', 'api.example.com', 'mail.example.com']
736
+ })
737
+ })
738
+
739
+ it('should process CSR with ACME client auto method', async () => {
740
+ const mockWebsite = createMockWebsiteConfig('example.com')
741
+ mockWebsite.cert.ssl = null // Force renewal
742
+ mockConfig.config.websites = {
743
+ 'example.com': mockWebsite
744
+ }
745
+
746
+ await SSL.check()
747
+
748
+ expect(mockClient.auto).toHaveBeenCalledWith({
749
+ csr: 'mock-csr',
750
+ termsOfServiceAgreed: true,
751
+ challengePriority: ['dns-01'],
752
+ challengeCreateFn: expect.any(Function),
753
+ challengeRemoveFn: expect.any(Function),
754
+ challengeKeyAuthorizationFn: expect.any(Function),
755
+ challengeTimeoutFn: expect.any(Function)
756
+ })
757
+ })
758
+
759
+ it('should agree to terms of service automatically', async () => {
760
+ const mockWebsite = createMockWebsiteConfig('example.com')
761
+ mockWebsite.cert.ssl = null
762
+ mockConfig.config.websites = {
763
+ 'example.com': mockWebsite
764
+ }
765
+
766
+ await SSL.check()
767
+
768
+ expect(mockClient.auto).toHaveBeenCalledWith(
769
+ expect.objectContaining({
770
+ termsOfServiceAgreed: true
771
+ })
772
+ )
773
+ })
774
+
775
+ it('should store certificate files after successful generation', async () => {
776
+ const mockWebsite = createMockWebsiteConfig('example.com')
777
+ mockWebsite.cert.ssl = null // Force renewal
778
+ mockConfig.config.websites = {
779
+ 'example.com': mockWebsite
780
+ }
781
+
782
+ // Mock directory doesn't exist to trigger creation
783
+ fs.existsSync.mockImplementation(path => {
784
+ if (path.includes('.candypack/cert/ssl')) {
785
+ return false
786
+ }
787
+ return true
788
+ })
789
+
790
+ await SSL.check()
791
+
792
+ expect(fs.mkdirSync).toHaveBeenCalledWith('/home/test/.candypack/cert/ssl', {recursive: true})
793
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/home/test/.candypack/cert/ssl/example.com.key', 'mock-key')
794
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/home/test/.candypack/cert/ssl/example.com.crt', 'mock-certificate')
795
+ })
796
+
797
+ it('should not create directory if it already exists', async () => {
798
+ const mockWebsite = createMockWebsiteConfig('example.com')
799
+ mockWebsite.cert.ssl = null
800
+ mockConfig.config.websites = {
801
+ 'example.com': mockWebsite
802
+ }
803
+
804
+ // Mock directory exists
805
+ fs.existsSync.mockReturnValue(true)
806
+
807
+ await SSL.check()
808
+
809
+ expect(fs.mkdirSync).not.toHaveBeenCalled()
810
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/home/test/.candypack/cert/ssl/example.com.key', 'mock-key')
811
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/home/test/.candypack/cert/ssl/example.com.crt', 'mock-certificate')
812
+ })
813
+
814
+ it('should update website configuration with new certificate', async () => {
815
+ const mockWebsite = createMockWebsiteConfig('example.com')
816
+ mockWebsite.cert.ssl = null // Force renewal
817
+ mockConfig.config.websites = {
818
+ 'example.com': mockWebsite
819
+ }
820
+
821
+ await SSL.check()
822
+
823
+ expect(mockWebsite.cert.ssl).toEqual({
824
+ key: '/home/test/.candypack/cert/ssl/example.com.key',
825
+ cert: '/home/test/.candypack/cert/ssl/example.com.crt',
826
+ expiry: expect.any(Number)
827
+ })
828
+ })
829
+
830
+ it('should set certificate expiry to 90 days from now', async () => {
831
+ const mockWebsite = createMockWebsiteConfig('example.com')
832
+ mockWebsite.cert.ssl = null
833
+ mockConfig.config.websites = {
834
+ 'example.com': mockWebsite
835
+ }
836
+
837
+ const beforeTime = Date.now()
838
+ await SSL.check()
839
+ const afterTime = Date.now()
840
+
841
+ const expectedExpiry = 1000 * 60 * 60 * 24 * 30 * 3 // 90 days
842
+ expect(mockWebsite.cert.ssl.expiry).toBeGreaterThanOrEqual(beforeTime + expectedExpiry - 1000)
843
+ expect(mockWebsite.cert.ssl.expiry).toBeLessThanOrEqual(afterTime + expectedExpiry + 1000)
844
+ })
845
+
846
+ describe('configuration updates after renewal', () => {
847
+ it('should update website configuration with new certificate paths', async () => {
848
+ const mockWebsite = createMockWebsiteConfig('example.com')
849
+ mockWebsite.cert.ssl = null // Force renewal
850
+ mockConfig.config.websites = {
851
+ 'example.com': mockWebsite
852
+ }
853
+
854
+ await SSL.check()
855
+
856
+ expect(mockWebsite.cert.ssl).toEqual({
857
+ key: '/home/test/.candypack/cert/ssl/example.com.key',
858
+ cert: '/home/test/.candypack/cert/ssl/example.com.crt',
859
+ expiry: expect.any(Number)
860
+ })
861
+ expect(mockConfig.config.websites['example.com']).toBe(mockWebsite)
862
+ })
863
+
864
+ it('should save configuration after certificate renewal', async () => {
865
+ const mockWebsite = createMockWebsiteConfig('example.com')
866
+ mockWebsite.cert.ssl = null
867
+ mockConfig.config.websites = {
868
+ 'example.com': mockWebsite
869
+ }
870
+
871
+ await SSL.check()
872
+
873
+ // Configuration should be updated with new certificate info
874
+ expect(mockConfig.config.websites['example.com'].cert.ssl).toBeDefined()
875
+ expect(mockConfig.config.websites['example.com'].cert.ssl.key).toBe('/home/test/.candypack/cert/ssl/example.com.key')
876
+ expect(mockConfig.config.websites['example.com'].cert.ssl.cert).toBe('/home/test/.candypack/cert/ssl/example.com.crt')
877
+ })
878
+ })
879
+ })
880
+
881
+ describe('challenge cleanup and DNS record removal', () => {
882
+ it('should clean up DNS records after successful challenge', async () => {
883
+ const mockWebsite = createMockWebsiteConfig('example.com')
884
+ mockWebsite.cert.ssl = null // Force renewal
885
+ mockConfig.config.websites = {
886
+ 'example.com': mockWebsite
887
+ }
888
+
889
+ // Mock the auto method to simulate successful challenge completion
890
+ mockClient.auto.mockImplementation(async options => {
891
+ // Simulate challenge creation
892
+ const authz = {identifier: {value: 'example.com'}}
893
+ const challenge = {type: 'dns-01'}
894
+ const keyAuthorization = 'test-key-auth'
895
+
896
+ await options.challengeCreateFn(authz, challenge, keyAuthorization)
897
+ await options.challengeRemoveFn(authz, challenge, keyAuthorization)
898
+
899
+ return 'mock-certificate'
900
+ })
901
+
902
+ await SSL.check()
903
+
904
+ expect(mockDNS.record).toHaveBeenCalledWith({
905
+ name: '_acme-challenge.example.com',
906
+ type: 'TXT',
907
+ value: 'test-key-auth',
908
+ ttl: 100,
909
+ unique: true
910
+ })
911
+
912
+ expect(mockDNS.delete).toHaveBeenCalledWith({
913
+ name: '_acme-challenge.example.com',
914
+ type: 'TXT',
915
+ value: 'test-key-auth'
916
+ })
917
+ })
918
+
919
+ it('should clean up DNS records for all subdomains', async () => {
920
+ const mockWebsite = createMockWebsiteConfig('example.com')
921
+ mockWebsite.subdomain = ['www', 'api']
922
+ mockWebsite.cert.ssl = null
923
+ mockConfig.config.websites = {
924
+ 'example.com': mockWebsite
925
+ }
926
+
927
+ // Mock the auto method to simulate challenges for multiple domains
928
+ mockClient.auto.mockImplementation(async options => {
929
+ const domains = ['example.com', 'www.example.com', 'api.example.com']
930
+
931
+ for (const domain of domains) {
932
+ const authz = {identifier: {value: domain}}
933
+ const challenge = {type: 'dns-01'}
934
+ const keyAuthorization = `${domain}-key-auth`
935
+
936
+ await options.challengeCreateFn(authz, challenge, keyAuthorization)
937
+ await options.challengeRemoveFn(authz, challenge, keyAuthorization)
938
+ }
939
+
940
+ return 'mock-certificate'
941
+ })
942
+
943
+ await SSL.check()
944
+
945
+ // Verify cleanup for all domains
946
+ expect(mockDNS.delete).toHaveBeenCalledWith({
947
+ name: '_acme-challenge.example.com',
948
+ type: 'TXT',
949
+ value: 'example.com-key-auth'
950
+ })
951
+ expect(mockDNS.delete).toHaveBeenCalledWith({
952
+ name: '_acme-challenge.www.example.com',
953
+ type: 'TXT',
954
+ value: 'www.example.com-key-auth'
955
+ })
956
+ expect(mockDNS.delete).toHaveBeenCalledWith({
957
+ name: '_acme-challenge.api.example.com',
958
+ type: 'TXT',
959
+ value: 'api.example.com-key-auth'
960
+ })
961
+ })
962
+ })
963
+ })
964
+
965
+ describe('self-signed certificate generation and error handling', () => {
966
+ describe('self-signed certificate generation with selfsigned module', () => {
967
+ it('should generate self-signed certificate when SSL config is missing', async () => {
968
+ mockConfig.config.websites = {
969
+ 'example.com': createMockWebsiteConfig('example.com')
970
+ }
971
+ mockConfig.config.ssl = null // No SSL config
972
+
973
+ // Mock directory doesn't exist to trigger creation
974
+ fs.existsSync.mockReturnValue(false)
975
+
976
+ await SSL.check()
977
+
978
+ expect(selfsigned.generate).toHaveBeenCalledWith([{name: 'commonName', value: 'CandyPack'}], {days: 365, keySize: 2048})
979
+ })
980
+
981
+ it('should generate self-signed certificate when SSL config is expired', async () => {
982
+ mockConfig.config.websites = {
983
+ 'example.com': createMockWebsiteConfig('example.com')
984
+ }
985
+ mockConfig.config.ssl = {
986
+ key: '/home/test/.candypack/cert/ssl/candypack.key',
987
+ cert: '/home/test/.candypack/cert/ssl/candypack.crt',
988
+ expiry: Date.now() - 86400000 // Expired yesterday
989
+ }
990
+
991
+ await SSL.check()
992
+
993
+ expect(selfsigned.generate).toHaveBeenCalledWith([{name: 'commonName', value: 'CandyPack'}], {days: 365, keySize: 2048})
994
+ })
995
+
996
+ it('should use correct certificate attributes for self-signed generation', async () => {
997
+ mockConfig.config.websites = {
998
+ 'example.com': createMockWebsiteConfig('example.com')
999
+ }
1000
+ mockConfig.config.ssl = null
1001
+
1002
+ await SSL.check()
1003
+
1004
+ expect(selfsigned.generate).toHaveBeenCalledWith([{name: 'commonName', value: 'CandyPack'}], {days: 365, keySize: 2048})
1005
+ })
1006
+
1007
+ it('should use correct options for self-signed certificate generation', async () => {
1008
+ mockConfig.config.websites = {
1009
+ 'example.com': createMockWebsiteConfig('example.com')
1010
+ }
1011
+ mockConfig.config.ssl = null
1012
+
1013
+ await SSL.check()
1014
+
1015
+ const expectedOptions = {days: 365, keySize: 2048}
1016
+ expect(selfsigned.generate).toHaveBeenCalledWith(expect.any(Array), expectedOptions)
1017
+ })
1018
+
1019
+ it('should not generate self-signed certificate when valid one exists', async () => {
1020
+ mockConfig.config.websites = {
1021
+ 'example.com': createMockWebsiteConfig('example.com')
1022
+ }
1023
+ mockConfig.config.ssl = {
1024
+ key: '/home/test/.candypack/cert/ssl/candypack.key',
1025
+ cert: '/home/test/.candypack/cert/ssl/candypack.crt',
1026
+ expiry: Date.now() + 86400000 // Valid for another day
1027
+ }
1028
+
1029
+ // Mock files exist
1030
+ fs.existsSync.mockReturnValue(true)
1031
+
1032
+ await SSL.check()
1033
+
1034
+ expect(selfsigned.generate).not.toHaveBeenCalled()
1035
+ })
1036
+
1037
+ it('should regenerate self-signed certificate when key file is missing', async () => {
1038
+ mockConfig.config.websites = {
1039
+ 'example.com': createMockWebsiteConfig('example.com')
1040
+ }
1041
+ mockConfig.config.ssl = {
1042
+ key: '/home/test/.candypack/cert/ssl/candypack.key',
1043
+ cert: '/home/test/.candypack/cert/ssl/candypack.crt',
1044
+ expiry: Date.now() + 86400000 // Valid expiry
1045
+ }
1046
+
1047
+ // Mock key file missing but cert file exists
1048
+ fs.existsSync.mockImplementation(path => {
1049
+ if (path.includes('candypack.key')) return false
1050
+ if (path.includes('candypack.crt')) return true
1051
+ return true
1052
+ })
1053
+
1054
+ await SSL.check()
1055
+
1056
+ expect(selfsigned.generate).toHaveBeenCalled()
1057
+ })
1058
+
1059
+ it('should regenerate self-signed certificate when cert file is missing', async () => {
1060
+ mockConfig.config.websites = {
1061
+ 'example.com': createMockWebsiteConfig('example.com')
1062
+ }
1063
+ mockConfig.config.ssl = {
1064
+ key: '/home/test/.candypack/cert/ssl/candypack.key',
1065
+ cert: '/home/test/.candypack/cert/ssl/candypack.crt',
1066
+ expiry: Date.now() + 86400000 // Valid expiry
1067
+ }
1068
+
1069
+ // Mock cert file missing but key file exists
1070
+ fs.existsSync.mockImplementation(path => {
1071
+ if (path.includes('candypack.key')) return true
1072
+ if (path.includes('candypack.crt')) return false
1073
+ return true
1074
+ })
1075
+
1076
+ await SSL.check()
1077
+
1078
+ expect(selfsigned.generate).toHaveBeenCalled()
1079
+ })
1080
+ })
1081
+
1082
+ describe('certificate file storage and configuration updates', () => {
1083
+ it('should create SSL directory if it does not exist', async () => {
1084
+ mockConfig.config.websites = {
1085
+ 'example.com': createMockWebsiteConfig('example.com')
1086
+ }
1087
+ mockConfig.config.ssl = null
1088
+
1089
+ // Mock directory doesn't exist
1090
+ fs.existsSync.mockImplementation(path => {
1091
+ if (path.includes('.candypack/cert/ssl')) return false
1092
+ return true
1093
+ })
1094
+
1095
+ await SSL.check()
1096
+
1097
+ expect(fs.mkdirSync).toHaveBeenCalledWith('/home/test/.candypack/cert/ssl', {recursive: true})
1098
+ })
1099
+
1100
+ it('should not create SSL directory if it already exists', async () => {
1101
+ mockConfig.config.websites = {
1102
+ 'example.com': createMockWebsiteConfig('example.com')
1103
+ }
1104
+ mockConfig.config.ssl = null
1105
+
1106
+ // Mock directory exists
1107
+ fs.existsSync.mockReturnValue(true)
1108
+
1109
+ await SSL.check()
1110
+
1111
+ expect(fs.mkdirSync).not.toHaveBeenCalled()
1112
+ })
1113
+
1114
+ it('should write self-signed private key to correct file path', async () => {
1115
+ mockConfig.config.websites = {
1116
+ 'example.com': createMockWebsiteConfig('example.com')
1117
+ }
1118
+ mockConfig.config.ssl = null
1119
+
1120
+ await SSL.check()
1121
+
1122
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
1123
+ '/home/test/.candypack/cert/ssl/candypack.key',
1124
+ '-----BEGIN PRIVATE KEY-----\nmock-private-key\n-----END PRIVATE KEY-----'
1125
+ )
1126
+ })
1127
+
1128
+ it('should write self-signed certificate to correct file path', async () => {
1129
+ mockConfig.config.websites = {
1130
+ 'example.com': createMockWebsiteConfig('example.com')
1131
+ }
1132
+ mockConfig.config.ssl = null
1133
+
1134
+ await SSL.check()
1135
+
1136
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
1137
+ '/home/test/.candypack/cert/ssl/candypack.crt',
1138
+ '-----BEGIN CERTIFICATE-----\nmock-certificate\n-----END CERTIFICATE-----'
1139
+ )
1140
+ })
1141
+
1142
+ it('should update SSL configuration with new certificate paths', async () => {
1143
+ mockConfig.config.websites = {
1144
+ 'example.com': createMockWebsiteConfig('example.com')
1145
+ }
1146
+ mockConfig.config.ssl = null
1147
+
1148
+ await SSL.check()
1149
+
1150
+ expect(mockConfig.config.ssl).toEqual({
1151
+ key: '/home/test/.candypack/cert/ssl/candypack.key',
1152
+ cert: '/home/test/.candypack/cert/ssl/candypack.crt',
1153
+ expiry: expect.any(Number)
1154
+ })
1155
+ })
1156
+
1157
+ it('should set self-signed certificate expiry to 24 hours from now', async () => {
1158
+ mockConfig.config.websites = {
1159
+ 'example.com': createMockWebsiteConfig('example.com')
1160
+ }
1161
+ mockConfig.config.ssl = null
1162
+
1163
+ const beforeTime = Date.now()
1164
+ await SSL.check()
1165
+ const afterTime = Date.now()
1166
+
1167
+ const expectedExpiry = 86400000 // 24 hours in milliseconds
1168
+ expect(mockConfig.config.ssl.expiry).toBeGreaterThanOrEqual(beforeTime + expectedExpiry - 1000)
1169
+ expect(mockConfig.config.ssl.expiry).toBeLessThanOrEqual(afterTime + expectedExpiry + 1000)
1170
+ })
1171
+
1172
+ it('should preserve existing SSL configuration when certificate is valid', async () => {
1173
+ const existingSSL = {
1174
+ key: '/home/test/.candypack/cert/ssl/candypack.key',
1175
+ cert: '/home/test/.candypack/cert/ssl/candypack.crt',
1176
+ expiry: Date.now() + 86400000 // Valid for another day
1177
+ }
1178
+
1179
+ mockConfig.config.websites = {
1180
+ 'example.com': createMockWebsiteConfig('example.com')
1181
+ }
1182
+ mockConfig.config.ssl = existingSSL
1183
+
1184
+ // Mock files exist
1185
+ fs.existsSync.mockReturnValue(true)
1186
+
1187
+ await SSL.check()
1188
+
1189
+ expect(mockConfig.config.ssl).toEqual(existingSSL)
1190
+ expect(selfsigned.generate).not.toHaveBeenCalled()
1191
+ })
1192
+ })
1193
+
1194
+ describe('error handling and retry logic for failed renewals', () => {
1195
+ it('should handle selfsigned.generate errors by throwing', async () => {
1196
+ mockConfig.config.websites = {
1197
+ 'example.com': createMockWebsiteConfig('example.com')
1198
+ }
1199
+ mockConfig.config.ssl = null
1200
+
1201
+ // Mock selfsigned.generate to throw an error
1202
+ selfsigned.generate.mockImplementation(() => {
1203
+ throw new Error('Certificate generation failed')
1204
+ })
1205
+
1206
+ // Should throw the error since SSL module doesn't handle it
1207
+ await expect(SSL.check()).rejects.toThrow('Certificate generation failed')
1208
+
1209
+ expect(selfsigned.generate).toHaveBeenCalled()
1210
+ })
1211
+
1212
+ it('should handle file system write errors by throwing', async () => {
1213
+ mockConfig.config.websites = {
1214
+ 'example.com': createMockWebsiteConfig('example.com')
1215
+ }
1216
+ mockConfig.config.ssl = null
1217
+
1218
+ // Mock fs.writeFileSync to throw an error only for self-signed cert files
1219
+ fs.writeFileSync.mockImplementation(path => {
1220
+ if (path.includes('candypack.key') || path.includes('candypack.crt')) {
1221
+ throw new Error('File write failed')
1222
+ }
1223
+ })
1224
+
1225
+ // Should throw the error since SSL module doesn't handle it
1226
+ await expect(SSL.check()).rejects.toThrow('File write failed')
1227
+
1228
+ expect(fs.writeFileSync).toHaveBeenCalled()
1229
+ })
1230
+
1231
+ it('should handle directory creation errors by throwing', async () => {
1232
+ mockConfig.config.websites = {
1233
+ 'example.com': createMockWebsiteConfig('example.com')
1234
+ }
1235
+ mockConfig.config.ssl = null
1236
+
1237
+ // Mock directory doesn't exist
1238
+ fs.existsSync.mockReturnValue(false)
1239
+
1240
+ // Mock fs.mkdirSync to throw an error
1241
+ fs.mkdirSync.mockImplementation(() => {
1242
+ throw new Error('Directory creation failed')
1243
+ })
1244
+
1245
+ // Should throw the error since SSL module doesn't handle it
1246
+ await expect(SSL.check()).rejects.toThrow('Directory creation failed')
1247
+
1248
+ expect(fs.mkdirSync).toHaveBeenCalled()
1249
+ })
1250
+
1251
+ it('should handle ACME client errors with retry logic', async () => {
1252
+ const mockWebsite = createMockWebsiteConfig('example.com')
1253
+ mockWebsite.cert.ssl = null // Force renewal
1254
+ mockConfig.config.websites = {
1255
+ 'example.com': mockWebsite
1256
+ }
1257
+
1258
+ // Create a local mock client for this test
1259
+ const localMockClient = {
1260
+ auto: jest.fn().mockRejectedValue(new Error('ACME challenge failed'))
1261
+ }
1262
+ acme.Client.mockImplementation(() => localMockClient)
1263
+
1264
+ await SSL.check()
1265
+
1266
+ // Should have attempted ACME renewal
1267
+ expect(localMockClient.auto).toHaveBeenCalled()
1268
+
1269
+ // Should log the error (verify error logging was called)
1270
+ expect(mockLog.error).toHaveBeenCalledWith(expect.any(Error))
1271
+ })
1272
+
1273
+ it('should implement exponential backoff for failed ACME renewals', async () => {
1274
+ const mockWebsite = createMockWebsiteConfig('example.com')
1275
+ mockWebsite.cert.ssl = null
1276
+ mockConfig.config.websites = {
1277
+ 'example.com': mockWebsite
1278
+ }
1279
+
1280
+ // Create a local mock client for this test
1281
+ const localMockClient = {
1282
+ auto: jest.fn().mockRejectedValue(new Error('ACME failed'))
1283
+ }
1284
+ acme.Client.mockImplementation(() => localMockClient)
1285
+
1286
+ // First attempt
1287
+ await SSL.check()
1288
+
1289
+ // Reset checking flag to allow second attempt
1290
+ // Note: We can't access private properties, so we'll test the behavior indirectly
1291
+
1292
+ // Second attempt should be blocked by retry interval
1293
+ await SSL.check()
1294
+
1295
+ // Should only attempt once due to retry logic (second call is blocked by checking flag)
1296
+ expect(localMockClient.auto).toHaveBeenCalledTimes(1)
1297
+ })
1298
+
1299
+ it('should limit retry attempts to prevent infinite loops', async () => {
1300
+ const mockWebsite = createMockWebsiteConfig('example.com')
1301
+ mockWebsite.cert.ssl = null
1302
+ mockConfig.config.websites = {
1303
+ 'example.com': mockWebsite
1304
+ }
1305
+
1306
+ // Create a local mock client for this test
1307
+ const localMockClient = {
1308
+ auto: jest.fn().mockRejectedValue(new Error('Persistent ACME failure'))
1309
+ }
1310
+ acme.Client.mockImplementation(() => localMockClient)
1311
+
1312
+ // First attempt will fail and set retry interval
1313
+ await SSL.check()
1314
+
1315
+ // Should have attempted once and logged error
1316
+ expect(localMockClient.auto).toHaveBeenCalled()
1317
+ expect(mockLog.error).toHaveBeenCalled()
1318
+ })
1319
+
1320
+ it('should reset error count after successful renewal', async () => {
1321
+ const mockWebsite = createMockWebsiteConfig('example.com')
1322
+ mockWebsite.cert.ssl = null
1323
+ mockConfig.config.websites = {
1324
+ 'example.com': mockWebsite
1325
+ }
1326
+
1327
+ // Create a local mock client for this test
1328
+ const localMockClient = {
1329
+ auto: jest.fn().mockRejectedValueOnce(new Error('Temporary failure')).mockResolvedValueOnce('mock-certificate')
1330
+ }
1331
+ acme.Client.mockImplementation(() => localMockClient)
1332
+
1333
+ // First failure
1334
+ await SSL.check()
1335
+ expect(mockLog.error).toHaveBeenCalledTimes(1)
1336
+
1337
+ // Note: We can't easily test the second attempt due to private state management
1338
+ // This test verifies the first failure is handled correctly
1339
+ expect(localMockClient.auto).toHaveBeenCalledTimes(1)
1340
+ })
1341
+ })
1342
+
1343
+ describe('certificate validation and format verification', () => {
1344
+ it('should validate self-signed certificate format from selfsigned module', async () => {
1345
+ mockConfig.config.websites = {
1346
+ 'example.com': createMockWebsiteConfig('example.com')
1347
+ }
1348
+ mockConfig.config.ssl = null
1349
+
1350
+ // Mock directory doesn't exist to trigger creation
1351
+ fs.existsSync.mockReturnValue(false)
1352
+
1353
+ await SSL.check()
1354
+
1355
+ // Verify the mock was called and returns properly formatted PEM certificates
1356
+ expect(selfsigned.generate).toHaveBeenCalled()
1357
+ const mockReturnValue = selfsigned.generate.mock.results[0].value
1358
+ expect(mockReturnValue.private).toContain('-----BEGIN PRIVATE KEY-----')
1359
+ expect(mockReturnValue.private).toContain('-----END PRIVATE KEY-----')
1360
+ expect(mockReturnValue.cert).toContain('-----BEGIN CERTIFICATE-----')
1361
+ expect(mockReturnValue.cert).toContain('-----END CERTIFICATE-----')
1362
+ })
1363
+
1364
+ it('should validate certificate file paths are correctly set', async () => {
1365
+ mockConfig.config.websites = {
1366
+ 'example.com': createMockWebsiteConfig('example.com')
1367
+ }
1368
+ mockConfig.config.ssl = null
1369
+
1370
+ // Mock directory doesn't exist to trigger creation
1371
+ fs.existsSync.mockReturnValue(false)
1372
+
1373
+ await SSL.check()
1374
+
1375
+ expect(mockConfig.config.ssl.key).toBe('/home/test/.candypack/cert/ssl/candypack.key')
1376
+ expect(mockConfig.config.ssl.cert).toBe('/home/test/.candypack/cert/ssl/candypack.crt')
1377
+ expect(typeof mockConfig.config.ssl.expiry).toBe('number')
1378
+ expect(mockConfig.config.ssl.expiry).toBeGreaterThan(Date.now())
1379
+ })
1380
+
1381
+ it('should validate certificate expiry is set correctly', async () => {
1382
+ mockConfig.config.websites = {
1383
+ 'example.com': createMockWebsiteConfig('example.com')
1384
+ }
1385
+ mockConfig.config.ssl = null
1386
+
1387
+ // Mock directory doesn't exist to trigger creation
1388
+ fs.existsSync.mockReturnValue(false)
1389
+
1390
+ const beforeTime = Date.now()
1391
+ await SSL.check()
1392
+ const afterTime = Date.now()
1393
+
1394
+ // Should be set to 24 hours from now (86400000 ms)
1395
+ expect(mockConfig.config.ssl.expiry).toBeGreaterThanOrEqual(beforeTime + 86400000 - 1000)
1396
+ expect(mockConfig.config.ssl.expiry).toBeLessThanOrEqual(afterTime + 86400000 + 1000)
1397
+ })
1398
+
1399
+ it('should validate certificate files are written with correct content', async () => {
1400
+ mockConfig.config.websites = {
1401
+ 'example.com': createMockWebsiteConfig('example.com')
1402
+ }
1403
+ mockConfig.config.ssl = null
1404
+
1405
+ // Mock directory doesn't exist to trigger creation
1406
+ fs.existsSync.mockReturnValue(false)
1407
+
1408
+ await SSL.check()
1409
+
1410
+ // Verify key file content
1411
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
1412
+ '/home/test/.candypack/cert/ssl/candypack.key',
1413
+ expect.stringContaining('-----BEGIN PRIVATE KEY-----')
1414
+ )
1415
+
1416
+ // Verify certificate file content
1417
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
1418
+ '/home/test/.candypack/cert/ssl/candypack.crt',
1419
+ expect.stringContaining('-----BEGIN CERTIFICATE-----')
1420
+ )
1421
+ })
1422
+
1423
+ it('should validate certificate attributes match expected values', async () => {
1424
+ mockConfig.config.websites = {
1425
+ 'example.com': createMockWebsiteConfig('example.com')
1426
+ }
1427
+ mockConfig.config.ssl = null
1428
+
1429
+ // Mock directory doesn't exist to trigger creation
1430
+ fs.existsSync.mockReturnValue(false)
1431
+
1432
+ await SSL.check()
1433
+
1434
+ expect(selfsigned.generate).toHaveBeenCalledWith([{name: 'commonName', value: 'CandyPack'}], {days: 365, keySize: 2048})
1435
+ })
1436
+
1437
+ it('should validate certificate generation parameters are secure', async () => {
1438
+ mockConfig.config.websites = {
1439
+ 'example.com': createMockWebsiteConfig('example.com')
1440
+ }
1441
+ mockConfig.config.ssl = null
1442
+
1443
+ // Mock directory doesn't exist to trigger creation
1444
+ fs.existsSync.mockReturnValue(false)
1445
+
1446
+ await SSL.check()
1447
+
1448
+ // Verify the call was made with secure parameters
1449
+ expect(selfsigned.generate).toHaveBeenCalledWith(
1450
+ expect.any(Array),
1451
+ expect.objectContaining({
1452
+ keySize: expect.any(Number),
1453
+ days: expect.any(Number)
1454
+ })
1455
+ )
1456
+
1457
+ // Get the actual options passed
1458
+ const callArgs = selfsigned.generate.mock.calls[0]
1459
+ const options = callArgs[1]
1460
+
1461
+ // Verify secure key size (2048 bits minimum)
1462
+ expect(options.keySize).toBeGreaterThanOrEqual(2048)
1463
+
1464
+ // Verify reasonable validity period (365 days)
1465
+ expect(options.days).toBe(365)
1466
+ })
1467
+
1468
+ it('should handle malformed certificate data gracefully', async () => {
1469
+ mockConfig.config.websites = {
1470
+ 'example.com': createMockWebsiteConfig('example.com')
1471
+ }
1472
+ mockConfig.config.ssl = null
1473
+
1474
+ // Mock directory doesn't exist to trigger creation
1475
+ fs.existsSync.mockReturnValue(false)
1476
+
1477
+ // Mock selfsigned to return malformed data
1478
+ selfsigned.generate.mockReturnValue({
1479
+ private: 'invalid-key-data',
1480
+ cert: 'invalid-cert-data'
1481
+ })
1482
+
1483
+ await SSL.check()
1484
+
1485
+ // Should still write the files even with malformed data
1486
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/home/test/.candypack/cert/ssl/candypack.key', 'invalid-key-data')
1487
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/home/test/.candypack/cert/ssl/candypack.crt', 'invalid-cert-data')
1488
+ })
1489
+ })
1490
+ })
1491
+ })