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,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
|
+
})
|