@postalsys/certs 1.0.12 → 1.0.14
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/.github/workflows/release.yaml +3 -5
- package/.github/workflows/test.yaml +21 -0
- package/CHANGELOG.md +17 -0
- package/README.md +122 -0
- package/package.json +6 -6
- package/test/acme-challenge.test.js +198 -0
- package/test/certs.test.js +277 -0
- package/test/helpers/mock-redis.js +162 -0
- package/test/settings.test.js +139 -0
- package/test/tools.test.js +149 -0
|
@@ -18,7 +18,7 @@ jobs:
|
|
|
18
18
|
with:
|
|
19
19
|
release-type: node
|
|
20
20
|
package-name: ${{vars.NPM_MODULE_NAME}}
|
|
21
|
-
pull-request-title-pattern:
|
|
21
|
+
pull-request-title-pattern: "chore${scope}: release ${version} [skip-ci]"
|
|
22
22
|
# The logic below handles the npm publication:
|
|
23
23
|
- uses: actions/checkout@v3
|
|
24
24
|
# these if statements ensure that a publication only occurs when
|
|
@@ -26,12 +26,10 @@ jobs:
|
|
|
26
26
|
if: ${{ steps.release.outputs.release_created }}
|
|
27
27
|
- uses: actions/setup-node@v3
|
|
28
28
|
with:
|
|
29
|
-
node-version:
|
|
30
|
-
registry-url:
|
|
29
|
+
node-version: 24
|
|
30
|
+
registry-url: "https://registry.npmjs.org"
|
|
31
31
|
if: ${{ steps.release.outputs.release_created }}
|
|
32
32
|
- run: npm ci
|
|
33
33
|
if: ${{ steps.release.outputs.release_created }}
|
|
34
34
|
- run: npm publish --provenance --access public
|
|
35
|
-
env:
|
|
36
|
-
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
|
37
35
|
if: ${{ steps.release.outputs.release_created }}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
on:
|
|
2
|
+
push:
|
|
3
|
+
branches:
|
|
4
|
+
- master
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
name: test
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
node-version: [20, 22, 24]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-node@v4
|
|
17
|
+
with:
|
|
18
|
+
node-version: ${{ matrix.node-version }}
|
|
19
|
+
cache: npm
|
|
20
|
+
- run: npm ci
|
|
21
|
+
- run: npm test
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.14](https://github.com/postalsys/certs/compare/v1.0.13...v1.0.14) (2026-03-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* add test suite and CI workflow ([1c5569c](https://github.com/postalsys/certs/commit/1c5569c02386e8ed167672a78980e6f621a8db4a))
|
|
9
|
+
* bumped deps ([2673ff0](https://github.com/postalsys/certs/commit/2673ff0db815f0b33b060afba3f9cdfe035cbd44))
|
|
10
|
+
* update test matrix to Node 20, 22, 24 ([c20356b](https://github.com/postalsys/certs/commit/c20356b27e8c5e648657c404c57f22375ded0d42))
|
|
11
|
+
|
|
12
|
+
## [1.0.13](https://github.com/postalsys/certs/compare/v1.0.12...v1.0.13) (2026-03-23)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* bumped deos ([3d8cc4c](https://github.com/postalsys/certs/commit/3d8cc4ce6f5a58c25f9780076dc8adc99f26d279))
|
|
18
|
+
* update release workflow to Node 24 and use trusted publishers ([2392f54](https://github.com/postalsys/certs/commit/2392f547f421bbf83f32ffe4e06cbbf3f4cfc80c))
|
|
19
|
+
|
|
3
20
|
## [1.0.12](https://github.com/postalsys/certs/compare/v1.0.11...v1.0.12) (2025-09-29)
|
|
4
21
|
|
|
5
22
|
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# @postalsys/certs
|
|
2
|
+
|
|
3
|
+
Manage Let's Encrypt SSL/TLS certificates with automatic acquisition, renewal, and storage via the ACME protocol. Certificates and ACME account data are stored in Redis. Supports ACME HTTP-01 challenges.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install @postalsys/certs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Requirements:** Node.js 15+ (for CAA record validation), Redis
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const Redis = require('ioredis');
|
|
17
|
+
const express = require('express');
|
|
18
|
+
const { Certs } = require('@postalsys/certs');
|
|
19
|
+
|
|
20
|
+
const redis = new Redis();
|
|
21
|
+
const app = express();
|
|
22
|
+
|
|
23
|
+
const certs = new Certs({
|
|
24
|
+
redis,
|
|
25
|
+
namespace: 'myapp',
|
|
26
|
+
|
|
27
|
+
acme: {
|
|
28
|
+
// Use 'production' and the production directory URL for real certificates
|
|
29
|
+
environment: 'production',
|
|
30
|
+
directoryUrl: 'https://acme-v02.api.letsencrypt.org/directory',
|
|
31
|
+
email: 'admin@example.com'
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
// Optional: encrypt private keys before storing in Redis
|
|
35
|
+
encryptFn: async (value) => {
|
|
36
|
+
// your encryption logic
|
|
37
|
+
return encryptedValue;
|
|
38
|
+
},
|
|
39
|
+
decryptFn: async (value) => {
|
|
40
|
+
// your decryption logic
|
|
41
|
+
return decryptedValue;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Retrieve or acquire a certificate
|
|
46
|
+
const certData = await certs.getCertificate('example.com');
|
|
47
|
+
// certData.cert - PEM certificate
|
|
48
|
+
// certData.privateKey - PEM private key
|
|
49
|
+
// certData.ca - array of CA chain certificates
|
|
50
|
+
// certData.validTo - expiration date
|
|
51
|
+
|
|
52
|
+
// ACME HTTP-01 challenge handler
|
|
53
|
+
app.get('/.well-known/acme-challenge/:token', (req, res) => {
|
|
54
|
+
const token = req.params.token;
|
|
55
|
+
const domain = req.get('host');
|
|
56
|
+
certs
|
|
57
|
+
.routeHandler(domain, token)
|
|
58
|
+
.then(challenge => {
|
|
59
|
+
res.status(200).set('content-type', 'text/plain').send(challenge);
|
|
60
|
+
})
|
|
61
|
+
.catch(err => {
|
|
62
|
+
res.status(err.responseCode || 500).send({
|
|
63
|
+
error: err.message,
|
|
64
|
+
code: err.code
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Constructor Options
|
|
71
|
+
|
|
72
|
+
| Option | Type | Default | Description |
|
|
73
|
+
|--------|------|---------|-------------|
|
|
74
|
+
| `redis` | Object | *required* | ioredis (or compatible) client instance |
|
|
75
|
+
| `namespace` | String | `undefined` | Key prefix for Redis storage |
|
|
76
|
+
| `encryptFn` | Function | identity | Async function to encrypt private keys before storage |
|
|
77
|
+
| `decryptFn` | Function | identity | Async function to decrypt private keys after retrieval |
|
|
78
|
+
| `acme.environment` | String | `'development'` | `'development'` (staging) or `'production'` |
|
|
79
|
+
| `acme.directoryUrl` | String | LE staging URL | ACME directory URL |
|
|
80
|
+
| `acme.email` | String | | Subscriber email for the ACME account |
|
|
81
|
+
| `acme.caaDomains` | Array | `['letsencrypt.org']` | Allowed CAA record domains |
|
|
82
|
+
| `acme.keyBits` | Number | `2048` | RSA key size for ACME account key |
|
|
83
|
+
| `acme.keyExponent` | Number | `65537` | RSA public exponent for ACME account key |
|
|
84
|
+
| `keyBits` | Number | `2048` | RSA key size for domain certificates |
|
|
85
|
+
| `keyExponent` | Number | `65537` | RSA public exponent for domain certificates |
|
|
86
|
+
| `logger` | Object | pino instance | Logger (pino-compatible) |
|
|
87
|
+
|
|
88
|
+
## API
|
|
89
|
+
|
|
90
|
+
### `Certs.create(options)`
|
|
91
|
+
|
|
92
|
+
Static factory method. Returns a new `Certs` instance.
|
|
93
|
+
|
|
94
|
+
### `getCertificate(domain, skipAcquire?)`
|
|
95
|
+
|
|
96
|
+
Returns stored certificate data for the domain. If the certificate is missing or expired, automatically acquires a new one via ACME unless `skipAcquire` is `true`.
|
|
97
|
+
|
|
98
|
+
Returns an object with `cert`, `privateKey`, `ca`, `validFrom`, `validTo`, `altNames`, `serialNumber`, `fingerprint`, `status`, and `lastError`, or `false` if no certificate exists.
|
|
99
|
+
|
|
100
|
+
### `acquireCert(domain)`
|
|
101
|
+
|
|
102
|
+
Forces certificate acquisition or renewal for the domain. Validates the domain name and CAA records, obtains a distributed lock, generates a CSR, and requests a certificate via ACME HTTP-01 challenge. Falls back to existing certificate data on error.
|
|
103
|
+
|
|
104
|
+
### `routeHandler(domain, token)`
|
|
105
|
+
|
|
106
|
+
Resolves an ACME HTTP-01 challenge. Use this as the handler for `GET /.well-known/acme-challenge/:token` requests. Returns the `keyAuthorization` string on success or throws with a `responseCode` property on failure.
|
|
107
|
+
|
|
108
|
+
### `listCertificateDomains()`
|
|
109
|
+
|
|
110
|
+
Returns a sorted array of all domain names that have certificate records.
|
|
111
|
+
|
|
112
|
+
### `deleteCertificateData(domain)`
|
|
113
|
+
|
|
114
|
+
Removes all stored certificate data for the domain.
|
|
115
|
+
|
|
116
|
+
## Automatic Renewal
|
|
117
|
+
|
|
118
|
+
Certificates are automatically renewed when retrieved via `getCertificate()` if they expire within 30 days. After a failed renewal attempt, a short safety lock prevents repeated retries.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
ISC
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postalsys/certs",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
4
4
|
"description": "Manage Let's Encrypt certificates",
|
|
5
5
|
"main": "lib/certs.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "node --test --test-force-exit test/*.test.js",
|
|
8
8
|
"update": "rm -rf node_modules package-lock.json && ncu -u && npm install"
|
|
9
9
|
},
|
|
10
10
|
"keywords": [],
|
|
@@ -21,15 +21,15 @@
|
|
|
21
21
|
"eslint-config-nodemailer": "1.2.0",
|
|
22
22
|
"eslint-config-prettier": "9.1.0",
|
|
23
23
|
"express": "4.19.2",
|
|
24
|
-
"ioredis": "5.
|
|
24
|
+
"ioredis": "5.10.1"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@root/acme": "3.1.0",
|
|
28
|
-
"ioredfour": "1.
|
|
29
|
-
"joi": "18.0.
|
|
28
|
+
"ioredfour": "1.4.1",
|
|
29
|
+
"joi": "18.0.2",
|
|
30
30
|
"msgpack5": "6.0.2",
|
|
31
31
|
"pem-jwk": "2.0.0",
|
|
32
|
-
"pino": "
|
|
32
|
+
"pino": "10.3.1",
|
|
33
33
|
"punycode.js": "2.3.1"
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, beforeEach } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const AcmeChallenge = require('../lib/acme-challenge');
|
|
6
|
+
const { createMockRedis } = require('./helpers/mock-redis');
|
|
7
|
+
|
|
8
|
+
describe('AcmeChallenge', () => {
|
|
9
|
+
let redis;
|
|
10
|
+
let challenge;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
redis = createMockRedis();
|
|
14
|
+
challenge = AcmeChallenge.create({ redis, namespace: 'test', ttl: 5000 });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('create', () => {
|
|
18
|
+
it('should return an AcmeChallenge instance', () => {
|
|
19
|
+
assert.ok(challenge instanceof AcmeChallenge);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should generate a uuid', () => {
|
|
23
|
+
assert.ok(challenge.uuid);
|
|
24
|
+
assert.equal(typeof challenge.uuid, 'string');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should use default TTL when not specified', () => {
|
|
28
|
+
const c = AcmeChallenge.create({ redis });
|
|
29
|
+
assert.equal(c.ttl, 2 * 3600 * 1000);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('init', () => {
|
|
34
|
+
it('should return null', () => {
|
|
35
|
+
assert.equal(challenge.init(), null);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('getKey', () => {
|
|
40
|
+
it('should prefix with namespace', () => {
|
|
41
|
+
assert.equal(challenge.getKey('foo'), 'test:certs:foo');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should work without namespace', () => {
|
|
45
|
+
const c = AcmeChallenge.create({ redis });
|
|
46
|
+
assert.equal(c.getKey('foo'), 'certs:foo');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('setData and getData', () => {
|
|
51
|
+
it('should roundtrip challenge data', async () => {
|
|
52
|
+
const data = { acme: { token: 'tok', secret: { value: 'auth123' } } };
|
|
53
|
+
await challenge.setData('example.com', 'tok', data);
|
|
54
|
+
const result = await challenge.getData('example.com', 'tok');
|
|
55
|
+
assert.deepEqual(result, data);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return false for non-existent data', async () => {
|
|
59
|
+
const result = await challenge.getData('missing.com', 'notoken');
|
|
60
|
+
assert.equal(result, false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('deleteData', () => {
|
|
65
|
+
it('should delete stored data', async () => {
|
|
66
|
+
await challenge.setData('example.com', 'tok', { test: true });
|
|
67
|
+
await challenge.deleteData('example.com', 'tok');
|
|
68
|
+
const result = await challenge.getData('example.com', 'tok');
|
|
69
|
+
assert.equal(result, false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('set', () => {
|
|
74
|
+
it('should store challenge when domain exists in settings', async () => {
|
|
75
|
+
await challenge.settings.set('domain:example.com:data', { domain: 'example.com' });
|
|
76
|
+
|
|
77
|
+
await challenge.set({
|
|
78
|
+
challenge: {
|
|
79
|
+
altname: 'example.com',
|
|
80
|
+
keyAuthorization: 'auth-value-123',
|
|
81
|
+
token: 'challenge-token'
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const data = await challenge.getData('example.com', 'challenge-token');
|
|
86
|
+
assert.ok(data);
|
|
87
|
+
assert.equal(data.acme.secret.value, 'auth-value-123');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should throw 404 if domain not in settings', async () => {
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
() =>
|
|
93
|
+
challenge.set({
|
|
94
|
+
challenge: {
|
|
95
|
+
altname: 'unknown.com',
|
|
96
|
+
keyAuthorization: 'auth',
|
|
97
|
+
token: 'tok'
|
|
98
|
+
}
|
|
99
|
+
}),
|
|
100
|
+
err => {
|
|
101
|
+
assert.equal(err.responseCode, 404);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('get', () => {
|
|
109
|
+
it('should return keyAuthorization for valid challenge', async () => {
|
|
110
|
+
await challenge.settings.set('domain:example.com:data', { domain: 'example.com' });
|
|
111
|
+
await challenge.set({
|
|
112
|
+
challenge: {
|
|
113
|
+
altname: 'example.com',
|
|
114
|
+
keyAuthorization: 'my-auth-key',
|
|
115
|
+
token: 'my-token'
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const result = await challenge.get({
|
|
120
|
+
challenge: {
|
|
121
|
+
identifier: { value: 'example.com' },
|
|
122
|
+
token: 'my-token'
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.ok(result);
|
|
127
|
+
assert.equal(result.keyAuthorization, 'my-auth-key');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should return null for non-existent challenge', async () => {
|
|
131
|
+
const result = await challenge.get({
|
|
132
|
+
challenge: {
|
|
133
|
+
identifier: { value: 'missing.com' },
|
|
134
|
+
token: 'notoken'
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
assert.equal(result, null);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should return null and delete expired challenge', async () => {
|
|
141
|
+
// Manually insert expired challenge data
|
|
142
|
+
await challenge.setData('example.com', 'expired-tok', {
|
|
143
|
+
acme: {
|
|
144
|
+
token: 'expired-tok',
|
|
145
|
+
secret: {
|
|
146
|
+
value: 'old-auth',
|
|
147
|
+
created: new Date(Date.now() - 10000),
|
|
148
|
+
expires: new Date(Date.now() - 1000)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await challenge.get({
|
|
154
|
+
challenge: {
|
|
155
|
+
identifier: { value: 'example.com' },
|
|
156
|
+
token: 'expired-tok'
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
assert.equal(result, null);
|
|
161
|
+
|
|
162
|
+
// Verify it was deleted
|
|
163
|
+
const data = await challenge.getData('example.com', 'expired-tok');
|
|
164
|
+
assert.equal(data, false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should return null for challenge with missing secret', async () => {
|
|
168
|
+
await challenge.setData('example.com', 'bad-tok', {
|
|
169
|
+
acme: { token: 'bad-tok' }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const result = await challenge.get({
|
|
173
|
+
challenge: {
|
|
174
|
+
identifier: { value: 'example.com' },
|
|
175
|
+
token: 'bad-tok'
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assert.equal(result, null);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('remove', () => {
|
|
184
|
+
it('should delete challenge data', async () => {
|
|
185
|
+
await challenge.setData('example.com', 'rm-tok', { acme: { token: 'rm-tok' } });
|
|
186
|
+
|
|
187
|
+
await challenge.remove({
|
|
188
|
+
challenge: {
|
|
189
|
+
identifier: { value: 'example.com' },
|
|
190
|
+
token: 'rm-tok'
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const data = await challenge.getData('example.com', 'rm-tok');
|
|
195
|
+
assert.equal(data, false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, beforeEach } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { Certs } = require('../lib/certs');
|
|
6
|
+
const { createMockRedis } = require('./helpers/mock-redis');
|
|
7
|
+
|
|
8
|
+
describe('Certs', () => {
|
|
9
|
+
let redis;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
redis = createMockRedis();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('create', () => {
|
|
16
|
+
it('should return a Certs instance', () => {
|
|
17
|
+
const certs = Certs.create({ redis });
|
|
18
|
+
assert.ok(certs instanceof Certs);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('constructor defaults', () => {
|
|
23
|
+
it('should set default acme options', () => {
|
|
24
|
+
const certs = new Certs({ redis });
|
|
25
|
+
assert.equal(certs.acmeOptions.environment, 'development');
|
|
26
|
+
assert.ok(certs.acmeOptions.directoryUrl.includes('staging'));
|
|
27
|
+
assert.deepEqual(certs.acmeOptions.caaDomains, ['letsencrypt.org']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should set default key parameters', () => {
|
|
31
|
+
const certs = new Certs({ redis });
|
|
32
|
+
assert.equal(certs.keyBits, 2048);
|
|
33
|
+
assert.equal(certs.keyExponent, 65537);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should use identity functions for encrypt/decrypt by default', async () => {
|
|
37
|
+
const certs = new Certs({ redis });
|
|
38
|
+
assert.equal(await certs.encryptFn('test'), 'test');
|
|
39
|
+
assert.equal(await certs.decryptFn('test'), 'test');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should accept custom encrypt/decrypt functions', async () => {
|
|
43
|
+
const certs = new Certs({
|
|
44
|
+
redis,
|
|
45
|
+
encryptFn: async v => 'enc:' + v,
|
|
46
|
+
decryptFn: async v => v.replace('enc:', '')
|
|
47
|
+
});
|
|
48
|
+
assert.equal(await certs.encryptFn('hello'), 'enc:hello');
|
|
49
|
+
assert.equal(await certs.decryptFn('enc:hello'), 'hello');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should accept custom key parameters', () => {
|
|
53
|
+
const certs = new Certs({ redis, keyBits: 4096, keyExponent: 3 });
|
|
54
|
+
assert.equal(certs.keyBits, 4096);
|
|
55
|
+
assert.equal(certs.keyExponent, 3);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should normalize caaDomains to array', () => {
|
|
59
|
+
const certs = new Certs({ redis, acme: { caaDomains: 'letsencrypt.org' } });
|
|
60
|
+
assert.ok(Array.isArray(certs.acmeOptions.caaDomains));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('getKey', () => {
|
|
65
|
+
it('should prefix with namespace', () => {
|
|
66
|
+
const certs = new Certs({ redis, namespace: 'myns' });
|
|
67
|
+
assert.equal(certs.getKey('certlist'), 'myns:certs:certlist');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should work without namespace', () => {
|
|
71
|
+
const certs = new Certs({ redis });
|
|
72
|
+
assert.equal(certs.getKey('certlist'), 'certs:certlist');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('validateDomain', () => {
|
|
77
|
+
it('should accept valid domains', async () => {
|
|
78
|
+
const certs = new Certs({ redis, acme: { caaDomains: [] } });
|
|
79
|
+
const result = await certs.validateDomain('example.com');
|
|
80
|
+
assert.equal(result, true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should reject invalid domain names', async () => {
|
|
84
|
+
const certs = new Certs({ redis });
|
|
85
|
+
await assert.rejects(() => certs.validateDomain('not a domain!'), err => {
|
|
86
|
+
assert.equal(err.responseCode, 400);
|
|
87
|
+
assert.equal(err.code, 'invalid_domain');
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should reject empty domain', async () => {
|
|
93
|
+
const certs = new Certs({ redis });
|
|
94
|
+
await assert.rejects(() => certs.validateDomain(''), err => {
|
|
95
|
+
assert.equal(err.responseCode, 400);
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('routeHandler', () => {
|
|
102
|
+
it('should reject invalid domain', async () => {
|
|
103
|
+
const certs = new Certs({ redis });
|
|
104
|
+
await assert.rejects(() => certs.routeHandler('not valid!', 'token123'), err => {
|
|
105
|
+
assert.equal(err.responseCode, 400);
|
|
106
|
+
assert.equal(err.code, 'InputValidationError');
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should reject empty token', async () => {
|
|
112
|
+
const certs = new Certs({ redis });
|
|
113
|
+
await assert.rejects(() => certs.routeHandler('example.com', ''), err => {
|
|
114
|
+
assert.equal(err.responseCode, 400);
|
|
115
|
+
return true;
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should reject token exceeding max length', async () => {
|
|
120
|
+
const certs = new Certs({ redis });
|
|
121
|
+
const longToken = 'a'.repeat(257);
|
|
122
|
+
await assert.rejects(() => certs.routeHandler('example.com', longToken), err => {
|
|
123
|
+
assert.equal(err.responseCode, 400);
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should return keyAuthorization for valid challenge', async () => {
|
|
129
|
+
const certs = new Certs({ redis, namespace: 'rt' });
|
|
130
|
+
|
|
131
|
+
await certs.settings.set('domain:example.com:data', { domain: 'example.com' });
|
|
132
|
+
await certs.acmeChallenge.set({
|
|
133
|
+
challenge: {
|
|
134
|
+
altname: 'example.com',
|
|
135
|
+
keyAuthorization: 'the-auth-key',
|
|
136
|
+
token: 'the-token'
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const result = await certs.routeHandler('example.com', 'the-token');
|
|
141
|
+
assert.equal(result, 'the-auth-key');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should throw 404 for unknown challenge', async () => {
|
|
145
|
+
const certs = new Certs({ redis });
|
|
146
|
+
await assert.rejects(() => certs.routeHandler('example.com', 'unknown'), err => {
|
|
147
|
+
assert.equal(err.responseCode, 404);
|
|
148
|
+
assert.equal(err.code, 'ChallengeNotFound');
|
|
149
|
+
return true;
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('listCertificateDomains', () => {
|
|
155
|
+
it('should return empty array when no domains', async () => {
|
|
156
|
+
const certs = new Certs({ redis });
|
|
157
|
+
const result = await certs.listCertificateDomains();
|
|
158
|
+
assert.deepEqual(result, []);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should return sorted domains', async () => {
|
|
162
|
+
const certs = new Certs({ redis, namespace: 'list' });
|
|
163
|
+
const key = certs.getKey('certlist');
|
|
164
|
+
await redis.sadd(key, 'zebra.com');
|
|
165
|
+
await redis.sadd(key, 'alpha.com');
|
|
166
|
+
await redis.sadd(key, 'mid.com');
|
|
167
|
+
|
|
168
|
+
const result = await certs.listCertificateDomains();
|
|
169
|
+
assert.deepEqual(result, ['alpha.com', 'mid.com', 'zebra.com']);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('setCertificateData and loadCertificateData', () => {
|
|
174
|
+
it('should store and load certificate data', async () => {
|
|
175
|
+
const certs = new Certs({ redis, namespace: 'data' });
|
|
176
|
+
|
|
177
|
+
await certs.setCertificateData('example.com', {
|
|
178
|
+
domain: 'example.com',
|
|
179
|
+
cert: '---PEM---',
|
|
180
|
+
ca: ['---CA---'],
|
|
181
|
+
privateKey: 'privkey',
|
|
182
|
+
status: 'valid',
|
|
183
|
+
validFrom: new Date('2025-01-01'),
|
|
184
|
+
validTo: new Date('2026-01-01'),
|
|
185
|
+
lastCheck: new Date(),
|
|
186
|
+
lastError: null
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const loaded = await certs.loadCertificateData('example.com');
|
|
190
|
+
assert.ok(loaded);
|
|
191
|
+
assert.equal(loaded.cert, '---PEM---');
|
|
192
|
+
assert.equal(loaded.privateKey, 'privkey');
|
|
193
|
+
assert.equal(loaded.status, 'valid');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should return false for non-existent domain', async () => {
|
|
197
|
+
const certs = new Certs({ redis, namespace: 'miss' });
|
|
198
|
+
const result = await certs.loadCertificateData('missing.com');
|
|
199
|
+
assert.equal(result, false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should encrypt private key on store and decrypt on load', async () => {
|
|
203
|
+
const certs = new Certs({
|
|
204
|
+
redis,
|
|
205
|
+
namespace: 'enc',
|
|
206
|
+
encryptFn: async v => (v ? 'ENC:' + v : v),
|
|
207
|
+
decryptFn: async v => (v && typeof v === 'string' ? v.replace('ENC:', '') : v)
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await certs.setCertificateData('example.com', {
|
|
211
|
+
domain: 'example.com',
|
|
212
|
+
privateKey: 'mysecretkey',
|
|
213
|
+
status: 'pending'
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const loaded = await certs.loadCertificateData('example.com');
|
|
217
|
+
assert.ok(loaded);
|
|
218
|
+
assert.equal(loaded.privateKey, 'mysecretkey');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('deleteCertificateData', () => {
|
|
223
|
+
it('should delete certificate data', async () => {
|
|
224
|
+
const certs = new Certs({ redis, namespace: 'del' });
|
|
225
|
+
|
|
226
|
+
await certs.setCertificateData('example.com', {
|
|
227
|
+
domain: 'example.com',
|
|
228
|
+
cert: 'cert',
|
|
229
|
+
status: 'valid'
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await certs.deleteCertificateData('example.com');
|
|
233
|
+
const result = await certs.loadCertificateData('example.com');
|
|
234
|
+
assert.equal(result, false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('getCertificate', () => {
|
|
239
|
+
it('should return valid certificate data', async () => {
|
|
240
|
+
const certs = new Certs({ redis, namespace: 'gc' });
|
|
241
|
+
|
|
242
|
+
await certs.setCertificateData('example.com', {
|
|
243
|
+
domain: 'example.com',
|
|
244
|
+
cert: 'certpem',
|
|
245
|
+
status: 'valid',
|
|
246
|
+
validFrom: new Date('2025-01-01'),
|
|
247
|
+
validTo: new Date('2027-01-01')
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const result = await certs.getCertificate('example.com', true);
|
|
251
|
+
assert.ok(result);
|
|
252
|
+
assert.equal(result.cert, 'certpem');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should return false with skipAcquire when no certificate exists', async () => {
|
|
256
|
+
const certs = new Certs({ redis, namespace: 'skip' });
|
|
257
|
+
const result = await certs.getCertificate('missing.com', true);
|
|
258
|
+
assert.equal(result, false);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should return existing data with skipAcquire even if expired', async () => {
|
|
262
|
+
const certs = new Certs({ redis, namespace: 'exp' });
|
|
263
|
+
|
|
264
|
+
await certs.setCertificateData('example.com', {
|
|
265
|
+
domain: 'example.com',
|
|
266
|
+
cert: 'oldcert',
|
|
267
|
+
status: 'valid',
|
|
268
|
+
validFrom: new Date('2020-01-01'),
|
|
269
|
+
validTo: new Date('2021-01-01')
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const result = await certs.getCertificate('example.com', true);
|
|
273
|
+
assert.ok(result);
|
|
274
|
+
assert.equal(result.cert, 'oldcert');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const msgpack = require('msgpack5')();
|
|
4
|
+
|
|
5
|
+
function createMockRedis() {
|
|
6
|
+
const hashes = new Map();
|
|
7
|
+
const keys = new Map();
|
|
8
|
+
const sets = new Map();
|
|
9
|
+
|
|
10
|
+
function getHash(key) {
|
|
11
|
+
if (!hashes.has(key)) {
|
|
12
|
+
hashes.set(key, new Map());
|
|
13
|
+
}
|
|
14
|
+
return hashes.get(key);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getSet(key) {
|
|
18
|
+
if (!sets.has(key)) {
|
|
19
|
+
sets.set(key, new Set());
|
|
20
|
+
}
|
|
21
|
+
return sets.get(key);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createMulti() {
|
|
25
|
+
const commands = [];
|
|
26
|
+
|
|
27
|
+
const chain = {
|
|
28
|
+
hmset(key, obj) {
|
|
29
|
+
commands.push(() => redis.hmset(key, obj));
|
|
30
|
+
return chain;
|
|
31
|
+
},
|
|
32
|
+
set(key, value) {
|
|
33
|
+
commands.push(() => redis.set(key, value));
|
|
34
|
+
return chain;
|
|
35
|
+
},
|
|
36
|
+
expire(key, ttl) {
|
|
37
|
+
commands.push(() => ttl);
|
|
38
|
+
return chain;
|
|
39
|
+
},
|
|
40
|
+
hdel(key, ...fields) {
|
|
41
|
+
commands.push(() => redis.hdel(key, ...fields));
|
|
42
|
+
return chain;
|
|
43
|
+
},
|
|
44
|
+
hincrby(key, field, increment) {
|
|
45
|
+
commands.push(() => redis.hincrby(key, field, increment));
|
|
46
|
+
return chain;
|
|
47
|
+
},
|
|
48
|
+
sadd(key, member) {
|
|
49
|
+
commands.push(() => redis.sadd(key, member));
|
|
50
|
+
return chain;
|
|
51
|
+
},
|
|
52
|
+
srem(key, member) {
|
|
53
|
+
commands.push(() => redis.srem(key, member));
|
|
54
|
+
return chain;
|
|
55
|
+
},
|
|
56
|
+
async exec() {
|
|
57
|
+
const results = [];
|
|
58
|
+
for (const cmd of commands) {
|
|
59
|
+
try {
|
|
60
|
+
const result = await cmd();
|
|
61
|
+
results.push([null, result]);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
results.push([err, null]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return chain;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const redis = {
|
|
74
|
+
async hmset(key, obj) {
|
|
75
|
+
const hash = getHash(key);
|
|
76
|
+
for (const [field, value] of Object.entries(obj)) {
|
|
77
|
+
hash.set(field, Buffer.isBuffer(value) ? value : Buffer.from(String(value)));
|
|
78
|
+
}
|
|
79
|
+
return 'OK';
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async hmgetBuffer(key, fields) {
|
|
83
|
+
const hash = getHash(key);
|
|
84
|
+
return fields.map(f => hash.get(f) || null);
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async hdel(key, ...fields) {
|
|
88
|
+
const hash = getHash(key);
|
|
89
|
+
let count = 0;
|
|
90
|
+
for (const f of fields) {
|
|
91
|
+
if (hash.delete(f)) count++;
|
|
92
|
+
}
|
|
93
|
+
return count;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async hexists(key, field) {
|
|
97
|
+
const hash = getHash(key);
|
|
98
|
+
return hash.has(field) ? 1 : 0;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async hget(key, field) {
|
|
102
|
+
const hash = getHash(key);
|
|
103
|
+
const val = hash.get(field);
|
|
104
|
+
return val ? val.toString() : null;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async hincrby(key, field, increment) {
|
|
108
|
+
const hash = getHash(key);
|
|
109
|
+
const current = hash.get(field) ? parseInt(hash.get(field).toString(), 10) : 0;
|
|
110
|
+
const newVal = current + increment;
|
|
111
|
+
hash.set(field, Buffer.from(String(newVal)));
|
|
112
|
+
return newVal;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async set(key, value) {
|
|
116
|
+
keys.set(key, Buffer.isBuffer(value) ? value : Buffer.from(String(value)));
|
|
117
|
+
return 'OK';
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async get(key) {
|
|
121
|
+
const val = keys.get(key);
|
|
122
|
+
return val ? val.toString() : null;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async getBuffer(key) {
|
|
126
|
+
return keys.get(key) || null;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async del(key) {
|
|
130
|
+
return keys.delete(key) ? 1 : 0;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async exists(key) {
|
|
134
|
+
return keys.has(key) ? 1 : 0;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async sadd(key, member) {
|
|
138
|
+
const s = getSet(key);
|
|
139
|
+
const had = s.has(member);
|
|
140
|
+
s.add(member);
|
|
141
|
+
return had ? 0 : 1;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async srem(key, member) {
|
|
145
|
+
const s = getSet(key);
|
|
146
|
+
return s.delete(member) ? 1 : 0;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async smembers(key) {
|
|
150
|
+
const s = sets.get(key);
|
|
151
|
+
return s ? Array.from(s) : [];
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
multi() {
|
|
155
|
+
return createMulti();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return redis;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { createMockRedis };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, beforeEach } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { Settings } = require('../lib/settings');
|
|
6
|
+
const { createMockRedis } = require('./helpers/mock-redis');
|
|
7
|
+
|
|
8
|
+
describe('Settings', () => {
|
|
9
|
+
let redis;
|
|
10
|
+
let settings;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
redis = createMockRedis();
|
|
14
|
+
settings = Settings.create({ redis, namespace: 'test' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('create', () => {
|
|
18
|
+
it('should return a Settings instance', () => {
|
|
19
|
+
assert.ok(settings instanceof Settings);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('getKey', () => {
|
|
24
|
+
it('should prefix with namespace', () => {
|
|
25
|
+
assert.equal(settings.getKey('settings'), 'test:certs:settings');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should work without namespace', () => {
|
|
29
|
+
const s = Settings.create({ redis });
|
|
30
|
+
assert.equal(s.getKey('settings'), 'certs:settings');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('set and get', () => {
|
|
35
|
+
it('should roundtrip a single key-value pair', async () => {
|
|
36
|
+
await settings.set('mykey', 'myvalue');
|
|
37
|
+
const result = await settings.get('mykey');
|
|
38
|
+
assert.equal(result, 'myvalue');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should roundtrip an object of key-value pairs', async () => {
|
|
42
|
+
await settings.set({ key1: 'val1', key2: 'val2' });
|
|
43
|
+
const result = await settings.get(['key1', 'key2']);
|
|
44
|
+
assert.equal(result.key1, 'val1');
|
|
45
|
+
assert.equal(result.key2, 'val2');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should handle object values', async () => {
|
|
49
|
+
const obj = { nested: { deep: true }, arr: [1, 2, 3] };
|
|
50
|
+
await settings.set('complex', obj);
|
|
51
|
+
const result = await settings.get('complex');
|
|
52
|
+
assert.deepEqual(result, obj);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should handle numeric values', async () => {
|
|
56
|
+
await settings.set('num', 42);
|
|
57
|
+
const result = await settings.get('num');
|
|
58
|
+
assert.equal(result, 42);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should handle null values', async () => {
|
|
62
|
+
await settings.set('empty', null);
|
|
63
|
+
const result = await settings.get('empty');
|
|
64
|
+
assert.equal(result, null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should return undefined for non-existent single key', async () => {
|
|
68
|
+
const result = await settings.get('nonexistent');
|
|
69
|
+
assert.equal(result, undefined);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should return object with undefined values for non-existent multiple keys', async () => {
|
|
73
|
+
const result = await settings.get(['a', 'b']);
|
|
74
|
+
assert.equal(result.a, undefined);
|
|
75
|
+
assert.equal(result.b, undefined);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should return false for invalid set arguments', async () => {
|
|
79
|
+
const result = await settings.set();
|
|
80
|
+
assert.equal(result, false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('delete', () => {
|
|
85
|
+
it('should delete existing keys', async () => {
|
|
86
|
+
await settings.set('todelete', 'value');
|
|
87
|
+
await settings.delete('todelete');
|
|
88
|
+
const result = await settings.get('todelete');
|
|
89
|
+
assert.equal(result, undefined);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should handle deleting non-existent keys', async () => {
|
|
93
|
+
const result = await settings.delete('nonexistent');
|
|
94
|
+
assert.equal(result, 0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('has', () => {
|
|
99
|
+
it('should return truthy for existing key', async () => {
|
|
100
|
+
await settings.set('exists', 'yes');
|
|
101
|
+
const result = await settings.has('exists');
|
|
102
|
+
assert.ok(result);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should return falsy for non-existent key', async () => {
|
|
106
|
+
const result = await settings.has('nope');
|
|
107
|
+
assert.ok(!result);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('getSet', () => {
|
|
112
|
+
it('should add hmset command to pipeline', async () => {
|
|
113
|
+
const multi = redis.multi();
|
|
114
|
+
const result = settings.getSet(multi, { pipekey: 'pipeval' });
|
|
115
|
+
assert.notEqual(result, false);
|
|
116
|
+
|
|
117
|
+
// Execute and verify
|
|
118
|
+
await result.exec();
|
|
119
|
+
const val = await settings.get('pipekey');
|
|
120
|
+
assert.equal(val, 'pipeval');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should accept key-value as separate args', async () => {
|
|
124
|
+
const multi = redis.multi();
|
|
125
|
+
const result = settings.getSet(multi, 'singlekey', 'singleval');
|
|
126
|
+
assert.notEqual(result, false);
|
|
127
|
+
|
|
128
|
+
await result.exec();
|
|
129
|
+
const val = await settings.get('singlekey');
|
|
130
|
+
assert.equal(val, 'singleval');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should return false for invalid arguments', () => {
|
|
134
|
+
const multi = redis.multi();
|
|
135
|
+
assert.equal(settings.getSet(multi), false);
|
|
136
|
+
assert.equal(settings.getSet(multi, 1, 2, 3), false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, before } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { normalizeDomain, generateKey, parseCertificate, validationErrors } = require('../lib/tools');
|
|
6
|
+
|
|
7
|
+
// Static self-signed cert with CN=test.example.com, SAN=DNS:test.example.com,DNS:www.example.com
|
|
8
|
+
const TEST_CERT = `-----BEGIN CERTIFICATE-----
|
|
9
|
+
MIIDRjCCAi6gAwIBAgIUDGr1Y4+8MTxJRO3g9lGDp+hgUeEwDQYJKoZIhvcNAQEL
|
|
10
|
+
BQAwGzEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLmNvbTAeFw0yNjAzMjMxMjU5NTda
|
|
11
|
+
Fw0yNzAzMjMxMjU5NTdaMBsxGTAXBgNVBAMMEHRlc3QuZXhhbXBsZS5jb20wggEi
|
|
12
|
+
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0E0ZqEGKPJLOsgK07VSb/3+CI
|
|
13
|
+
8ID6D90bd0u5BFzoZ6TvVy3c8SKQxlmLtxPCRSBkSUfzGeJgc6iSYTe5sUkvXtdP
|
|
14
|
+
J6qSy/cg51hSdK1oyso0fv5elqFXNnffJ7rOuONLHEm4hv5PTaDqKmsh27iWmI/e
|
|
15
|
+
4sr2+z66+19bCdRCDDMqNbyveMFLvb8XsgV020d5HI9cPierTsVH+DRwk0ODJVfl
|
|
16
|
+
SzcKgPoIcAZBRr7GSZ7mwpHzYGQMf8W3sa148BjvojCb4hJf1q8CH7ZQiVahlokZ
|
|
17
|
+
hw0R/zs/0kD5CLdaOoevT9ibiZHeI2HY1YaOg5lZKbyUnNWC/roE0IvpAcd5AgMB
|
|
18
|
+
AAGjgYEwfzAdBgNVHQ4EFgQUFWPYVG/9myi+0z60aGkaV0iy/WMwHwYDVR0jBBgw
|
|
19
|
+
FoAUFWPYVG/9myi+0z60aGkaV0iy/WMwDwYDVR0TAQH/BAUwAwEB/zAsBgNVHREE
|
|
20
|
+
JTAjghB0ZXN0LmV4YW1wbGUuY29tgg93d3cuZXhhbXBsZS5jb20wDQYJKoZIhvcN
|
|
21
|
+
AQELBQADggEBAKs1ACCedoZo1DEgtevPNk8PPAtLUHGXst+HVf8w+TFDgo4ICPUJ
|
|
22
|
+
8/8QXfcd5obzLSb+aBTGvSvu0WKce2aRkHY7OM9GSzyHwwXDsHoOrAnpjgyS6sbZ
|
|
23
|
+
RpiOMWSxnfSL+a+6drc9bc4dylCsDOYr2tAwnyaEPNs++Y1jk0gZYuHr9xmjVT8W
|
|
24
|
+
wQyi66bOjBdalHReVyOrQKHQWA+oWng24nHBe33IbV6BU21OyRnmwbBPM2KDcAts
|
|
25
|
+
1Lj2DVby5K8jFAAHOt767nofpxeb8wi504UkBe8DT10x+uxuH1GUh1Qj2Xp1aMbb
|
|
26
|
+
oCw05NWfWop5EANNovcHvYyBe9CLmEhKhu0=
|
|
27
|
+
-----END CERTIFICATE-----`;
|
|
28
|
+
|
|
29
|
+
describe('normalizeDomain', () => {
|
|
30
|
+
it('should lowercase and trim a domain', () => {
|
|
31
|
+
assert.equal(normalizeDomain(' Example.COM '), 'example.com');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return empty string for null/undefined', () => {
|
|
35
|
+
assert.equal(normalizeDomain(null), '');
|
|
36
|
+
assert.equal(normalizeDomain(undefined), '');
|
|
37
|
+
assert.equal(normalizeDomain(''), '');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should convert punycode to unicode', () => {
|
|
41
|
+
const result = normalizeDomain('xn--nxasmq6b');
|
|
42
|
+
assert.notEqual(result, 'xn--nxasmq6b');
|
|
43
|
+
assert.ok(result.length > 0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should handle non-punycode domains unchanged', () => {
|
|
47
|
+
assert.equal(normalizeDomain('example.com'), 'example.com');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should not throw on invalid punycode', () => {
|
|
51
|
+
const result = normalizeDomain('xn--');
|
|
52
|
+
assert.equal(typeof result, 'string');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should handle already lowercase domains', () => {
|
|
56
|
+
assert.equal(normalizeDomain('test.example.com'), 'test.example.com');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('generateKey', () => {
|
|
61
|
+
it('should generate a valid PEM private key', async () => {
|
|
62
|
+
const key = await generateKey(1024);
|
|
63
|
+
assert.ok(key.startsWith('-----BEGIN RSA PRIVATE KEY-----'));
|
|
64
|
+
assert.ok(key.includes('-----END RSA PRIVATE KEY-----'));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('parseCertificate', () => {
|
|
69
|
+
let parsed;
|
|
70
|
+
|
|
71
|
+
before(() => {
|
|
72
|
+
parsed = parseCertificate(TEST_CERT);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should parse serial number', () => {
|
|
76
|
+
assert.ok(parsed.serialNumber);
|
|
77
|
+
assert.equal(typeof parsed.serialNumber, 'string');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should parse fingerprint', () => {
|
|
81
|
+
assert.ok(parsed.fingerprint);
|
|
82
|
+
assert.ok(parsed.fingerprint.includes(':'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should parse alt names', () => {
|
|
86
|
+
assert.ok(Array.isArray(parsed.altNames));
|
|
87
|
+
assert.ok(parsed.altNames.includes('test.example.com'));
|
|
88
|
+
assert.ok(parsed.altNames.includes('www.example.com'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should parse validity dates', () => {
|
|
92
|
+
assert.ok(parsed.validFrom instanceof Date);
|
|
93
|
+
assert.ok(parsed.validTo instanceof Date);
|
|
94
|
+
assert.ok(parsed.validTo > parsed.validFrom);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should deduplicate domain names', () => {
|
|
98
|
+
const unique = new Set(parsed.altNames);
|
|
99
|
+
assert.equal(parsed.altNames.length, unique.size);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should throw on invalid certificate', () => {
|
|
103
|
+
assert.throws(() => parseCertificate('not a cert'), { name: 'Error' });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('validationErrors', () => {
|
|
108
|
+
it('should extract errors from validation result', () => {
|
|
109
|
+
const result = validationErrors({
|
|
110
|
+
error: {
|
|
111
|
+
details: [{ path: 'email', message: 'Email is required' }]
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
assert.deepEqual(result, { email: 'Email is required' });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should handle multiple errors on different paths', () => {
|
|
118
|
+
const result = validationErrors({
|
|
119
|
+
error: {
|
|
120
|
+
details: [
|
|
121
|
+
{ path: 'email', message: 'Email is required' },
|
|
122
|
+
{ path: 'name', message: 'Name is required' }
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
assert.deepEqual(result, {
|
|
127
|
+
email: 'Email is required',
|
|
128
|
+
name: 'Name is required'
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should keep only first error per path', () => {
|
|
133
|
+
const result = validationErrors({
|
|
134
|
+
error: {
|
|
135
|
+
details: [
|
|
136
|
+
{ path: 'email', message: 'First error' },
|
|
137
|
+
{ path: 'email', message: 'Second error' }
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
assert.deepEqual(result, { email: 'First error' });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should return empty object when no errors', () => {
|
|
145
|
+
assert.deepEqual(validationErrors({}), {});
|
|
146
|
+
assert.deepEqual(validationErrors({ error: {} }), {});
|
|
147
|
+
assert.deepEqual(validationErrors({ error: { details: [] } }), {});
|
|
148
|
+
});
|
|
149
|
+
});
|