pending-dns 1.2.5 → 1.3.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/.github/codeql/codeql-config.yml +11 -0
- package/.github/workflows/codeql.yml +52 -0
- package/.github/workflows/deploy.yml +16 -3
- package/.github/workflows/release.yaml +43 -0
- package/.github/workflows/test.yml +75 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +97 -0
- package/README.md +28 -5
- package/SECURITY.md +88 -0
- package/SECURITY.txt +27 -0
- package/bin/pending-dns.js +1 -1
- package/config/default.toml +5 -0
- package/config/test.toml +25 -0
- package/eslint.config.js +38 -0
- package/lib/api-server.js +13 -6
- package/lib/cached-resolver.js +5 -3
- package/lib/certs.js +11 -4
- package/lib/dns-handler.js +13 -8
- package/lib/dns-server.js +30 -18
- package/lib/dns-tcp-server.js +1 -1
- package/lib/dns-udp-server.js +1 -1
- package/lib/logger.js +3 -0
- package/lib/public-server.js +20 -2
- package/lib/sentry.js +72 -0
- package/lib/tools.js +1 -1
- package/lib/zone-store.js +4 -4
- package/package.json +43 -33
- package/release-please-config.json +13 -0
- package/server.js +5 -24
- package/systemd/pending-dns.service +4 -4
- package/test/api.test.js +139 -0
- package/test/cached-resolver.test.js +57 -0
- package/test/certs.test.js +34 -0
- package/test/dns-handler.test.js +140 -0
- package/test/dns-server.test.js +69 -0
- package/test/helpers.js +25 -0
- package/test/sentry.test.js +21 -0
- package/test/tools.test.js +48 -0
- package/test/zone-store.test.js +209 -0
- package/workers/api.js +3 -1
- package/workers/dns.js +2 -24
- package/workers/health.js +3 -26
- package/workers/public.js +3 -25
- package/.eslintrc +0 -14
- package/Gruntfile.js +0 -16
package/test/api.test.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const { createServer } = require('../lib/api-server');
|
|
7
|
+
const { flushTestDb, closeDb } = require('./helpers');
|
|
8
|
+
|
|
9
|
+
let server;
|
|
10
|
+
|
|
11
|
+
test.before(async () => {
|
|
12
|
+
server = await createServer();
|
|
13
|
+
await server.initialize();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test.after(async () => {
|
|
17
|
+
if (server) {
|
|
18
|
+
await server.stop();
|
|
19
|
+
}
|
|
20
|
+
await closeDb();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test.beforeEach(async () => {
|
|
24
|
+
await flushTestDb();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const inject = opts => server.inject(opts);
|
|
28
|
+
|
|
29
|
+
test('GET records returns an empty list for an unknown zone', async () => {
|
|
30
|
+
const res = await inject({ method: 'GET', url: '/v1/zone/example.com/records' });
|
|
31
|
+
assert.equal(res.statusCode, 200);
|
|
32
|
+
const body = JSON.parse(res.payload);
|
|
33
|
+
assert.equal(body.zone, 'example.com');
|
|
34
|
+
assert.deepEqual(body.records, []);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('unknown routes return 404', async () => {
|
|
38
|
+
const res = await inject({ method: 'GET', url: '/does/not/exist' });
|
|
39
|
+
assert.equal(res.statusCode, 404);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('POST creates an A record and GET lists it alongside SOA/NS', async () => {
|
|
43
|
+
const create = await inject({
|
|
44
|
+
method: 'POST',
|
|
45
|
+
url: '/v1/zone/example.com/records',
|
|
46
|
+
payload: { subdomain: 'www', type: 'A', address: '1.2.3.4' }
|
|
47
|
+
});
|
|
48
|
+
assert.equal(create.statusCode, 200);
|
|
49
|
+
const created = JSON.parse(create.payload);
|
|
50
|
+
assert.ok(created.record, 'should return the new record id');
|
|
51
|
+
|
|
52
|
+
const res = await inject({ method: 'GET', url: '/v1/zone/example.com/records' });
|
|
53
|
+
const body = JSON.parse(res.payload);
|
|
54
|
+
|
|
55
|
+
const a = body.records.find(r => r.type === 'A');
|
|
56
|
+
assert.ok(a);
|
|
57
|
+
assert.equal(a.address, '1.2.3.4');
|
|
58
|
+
assert.equal(a.subdomain, 'www');
|
|
59
|
+
|
|
60
|
+
// system records are appended with id=null
|
|
61
|
+
assert.ok(body.records.some(r => r.type === 'SOA' && r.id === null));
|
|
62
|
+
assert.ok(body.records.some(r => r.type === 'NS' && r.id === null));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('POST rejects an A record without an address', async () => {
|
|
66
|
+
const res = await inject({
|
|
67
|
+
method: 'POST',
|
|
68
|
+
url: '/v1/zone/example.com/records',
|
|
69
|
+
payload: { type: 'A' }
|
|
70
|
+
});
|
|
71
|
+
assert.equal(res.statusCode, 400);
|
|
72
|
+
const body = JSON.parse(res.payload);
|
|
73
|
+
assert.ok(Array.isArray(body.fields), 'validation errors are reported in the fields array');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('POST creates a CAA record and stores its tag', async () => {
|
|
77
|
+
// Regression: the CAA "tag" field must be accepted and persisted.
|
|
78
|
+
const create = await inject({
|
|
79
|
+
method: 'POST',
|
|
80
|
+
url: '/v1/zone/example.com/records',
|
|
81
|
+
payload: { type: 'CAA', value: 'letsencrypt.org', tag: 'issue', flags: 0 }
|
|
82
|
+
});
|
|
83
|
+
assert.equal(create.statusCode, 200, `expected CAA create to succeed, got ${create.payload}`);
|
|
84
|
+
|
|
85
|
+
const res = await inject({ method: 'GET', url: '/v1/zone/example.com/records' });
|
|
86
|
+
const body = JSON.parse(res.payload);
|
|
87
|
+
const caa = body.records.find(r => r.type === 'CAA' && r.id);
|
|
88
|
+
assert.ok(caa, 'CAA record should be listed');
|
|
89
|
+
assert.equal(caa.value, 'letsencrypt.org');
|
|
90
|
+
assert.equal(caa.tag, 'issue');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('PUT updates a record and echoes back the record id', async () => {
|
|
94
|
+
const create = await inject({
|
|
95
|
+
method: 'POST',
|
|
96
|
+
url: '/v1/zone/example.com/records',
|
|
97
|
+
payload: { subdomain: 'www', type: 'A', address: '1.2.3.4' }
|
|
98
|
+
});
|
|
99
|
+
const id = JSON.parse(create.payload).record;
|
|
100
|
+
|
|
101
|
+
const update = await inject({
|
|
102
|
+
method: 'PUT',
|
|
103
|
+
url: `/v1/zone/example.com/records/${id}`,
|
|
104
|
+
payload: { subdomain: 'www', type: 'A', address: '5.6.7.8' }
|
|
105
|
+
});
|
|
106
|
+
assert.equal(update.statusCode, 200);
|
|
107
|
+
const body = JSON.parse(update.payload);
|
|
108
|
+
assert.equal(body.zone, 'example.com');
|
|
109
|
+
assert.ok(body.record, 'PUT should return the (possibly new) record id under "record"');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('DELETE removes a record', async () => {
|
|
113
|
+
const create = await inject({
|
|
114
|
+
method: 'POST',
|
|
115
|
+
url: '/v1/zone/example.com/records',
|
|
116
|
+
payload: { type: 'A', address: '1.2.3.4' }
|
|
117
|
+
});
|
|
118
|
+
const id = JSON.parse(create.payload).record;
|
|
119
|
+
|
|
120
|
+
const del = await inject({ method: 'DELETE', url: `/v1/zone/example.com/records/${id}` });
|
|
121
|
+
assert.equal(del.statusCode, 200);
|
|
122
|
+
const body = JSON.parse(del.payload);
|
|
123
|
+
assert.equal(body.deleted, true);
|
|
124
|
+
|
|
125
|
+
const res = await inject({ method: 'GET', url: '/v1/zone/example.com/records' });
|
|
126
|
+
assert.deepEqual(JSON.parse(res.payload).records, []);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('POST /v1/acme requires at least one domain', async () => {
|
|
130
|
+
// Fails Joi validation (min 1) before the handler runs, so no ACME/network work.
|
|
131
|
+
const res = await inject({ method: 'POST', url: '/v1/acme', payload: { domains: [] } });
|
|
132
|
+
assert.equal(res.statusCode, 400);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// NB: the "domain without a managed zone" rejection path is intentionally not
|
|
136
|
+
// exercised here - the /v1/acme handler calls acme.init() (a live Let's Encrypt
|
|
137
|
+
// directory fetch) before the zone check, which is slow and network-dependent.
|
|
138
|
+
// The underlying behaviour is covered offline by the zone-store tests
|
|
139
|
+
// (resolveDomainZone returns false for unknown domains).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const cachedResolver = require('../lib/cached-resolver');
|
|
7
|
+
const { db, flushTestDb, closeDb } = require('./helpers');
|
|
8
|
+
|
|
9
|
+
test.after(async () => {
|
|
10
|
+
await closeDb();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test.beforeEach(async () => {
|
|
14
|
+
await flushTestDb();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const cacheKey = (target, type) => ['d', 'cache', target, type].join(':');
|
|
18
|
+
|
|
19
|
+
test('successful lookups are cached in Redis with a TTL', async t => {
|
|
20
|
+
let resolved;
|
|
21
|
+
try {
|
|
22
|
+
resolved = await cachedResolver('one.one.one.one', 'A');
|
|
23
|
+
} catch (err) {
|
|
24
|
+
t.skip(`network unavailable: ${err.message}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
assert.ok(Array.isArray(resolved) && resolved.length, 'should resolve at least one address');
|
|
29
|
+
|
|
30
|
+
const ttl = await db.redisRead.ttl(cacheKey('one.one.one.one', 'A'));
|
|
31
|
+
assert.ok(ttl > 0, 'a positive TTL should be set on the cache entry');
|
|
32
|
+
|
|
33
|
+
// a second call returns the cached value
|
|
34
|
+
const again = await cachedResolver('one.one.one.one', 'A');
|
|
35
|
+
assert.deepEqual(again.slice().sort(), resolved.slice().sort());
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('failed lookups cache an error with a bounded TTL', async t => {
|
|
39
|
+
const bogus = 'does-not-exist-pendingdns-test.invalid';
|
|
40
|
+
|
|
41
|
+
let threw = false;
|
|
42
|
+
try {
|
|
43
|
+
await cachedResolver(bogus, 'A');
|
|
44
|
+
} catch (err) {
|
|
45
|
+
threw = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!threw) {
|
|
49
|
+
t.skip('resolver unexpectedly succeeded; cannot exercise the error path');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ttl = await db.redisRead.ttl(cacheKey(bogus, 'A'));
|
|
54
|
+
// Regression: the error entry must have a real, positive expiry (not NaN/-1).
|
|
55
|
+
assert.ok(ttl > 0, `error cache entry must expire (ttl was ${ttl})`);
|
|
56
|
+
assert.ok(ttl <= 60 * 60, 'error TTL should be bounded by errorMaxTtl (1h)');
|
|
57
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const { pem2jwk } = require('pem-jwk');
|
|
8
|
+
const certs = require('../lib/certs');
|
|
9
|
+
const { closeDb } = require('./helpers');
|
|
10
|
+
|
|
11
|
+
const { generateKey } = certs.testables;
|
|
12
|
+
|
|
13
|
+
test.after(async () => {
|
|
14
|
+
await closeDb();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('generateKey returns a PKCS#1 RSA private key PEM', async () => {
|
|
18
|
+
const pem = await generateKey(2048);
|
|
19
|
+
assert.match(pem, /^-----BEGIN RSA PRIVATE KEY-----/);
|
|
20
|
+
assert.match(pem, /-----END RSA PRIVATE KEY-----\s*$/);
|
|
21
|
+
|
|
22
|
+
// the key must be loadable by Node's crypto
|
|
23
|
+
const keyObject = crypto.createPrivateKey(pem);
|
|
24
|
+
assert.equal(keyObject.asymmetricKeyType, 'rsa');
|
|
25
|
+
assert.equal(keyObject.asymmetricKeyDetails.modulusLength, 2048);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('generated key converts to a JWK via pem-jwk (as the ACME flow needs)', async () => {
|
|
29
|
+
const pem = await generateKey(2048);
|
|
30
|
+
const jwk = pem2jwk(pem);
|
|
31
|
+
assert.equal(jwk.kty, 'RSA');
|
|
32
|
+
assert.ok(jwk.n, 'modulus present');
|
|
33
|
+
assert.ok(jwk.d, 'private exponent present');
|
|
34
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const dns2 = require('dns2');
|
|
7
|
+
const Packet = dns2.Packet;
|
|
8
|
+
|
|
9
|
+
const dnsHandler = require('../lib/dns-handler');
|
|
10
|
+
const { zoneStore } = require('../lib/zone-store');
|
|
11
|
+
const { db, flushTestDb, closeDb } = require('./helpers');
|
|
12
|
+
|
|
13
|
+
const { formatTXTData, shuffle, filterUnhealthy, reversedTypes } = dnsHandler.testables;
|
|
14
|
+
|
|
15
|
+
test.after(async () => {
|
|
16
|
+
await closeDb();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Pure helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
test('formatTXTData always returns an array of chunks', () => {
|
|
24
|
+
const short = formatTXTData('hello');
|
|
25
|
+
assert.ok(Array.isArray(short));
|
|
26
|
+
assert.deepEqual(short, ['hello']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('formatTXTData splits long values into <=255 byte chunks that recombine', () => {
|
|
30
|
+
const long = 'x'.repeat(600);
|
|
31
|
+
const chunks = formatTXTData(long);
|
|
32
|
+
assert.ok(Array.isArray(chunks));
|
|
33
|
+
assert.ok(chunks.length > 1);
|
|
34
|
+
for (const chunk of chunks) {
|
|
35
|
+
assert.ok(chunk.length <= 255);
|
|
36
|
+
}
|
|
37
|
+
assert.equal(chunks.join(''), long);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('formatTXTData coerces non-strings', () => {
|
|
41
|
+
assert.deepEqual(formatTXTData(undefined), ['']);
|
|
42
|
+
assert.deepEqual(formatTXTData(null), ['']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('shuffle returns the same elements (a permutation)', () => {
|
|
46
|
+
const input = [1, 2, 3, 4, 5, 6, 7, 8];
|
|
47
|
+
const out = shuffle(input.slice());
|
|
48
|
+
assert.equal(out.length, input.length);
|
|
49
|
+
assert.deepEqual(out.slice().sort((a, b) => a - b), input);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('filterUnhealthy drops unhealthy entries when at least one is healthy', () => {
|
|
53
|
+
const list = [
|
|
54
|
+
{ address: '1.1.1.1', health: { status: true } },
|
|
55
|
+
{ address: '2.2.2.2', health: { status: false } },
|
|
56
|
+
{ address: '3.3.3.3' } // no health info -> treated as healthy
|
|
57
|
+
];
|
|
58
|
+
const out = filterUnhealthy(list);
|
|
59
|
+
assert.deepEqual(
|
|
60
|
+
out.map(e => e.address),
|
|
61
|
+
['1.1.1.1', '3.3.3.3']
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('filterUnhealthy returns all entries when none are healthy', () => {
|
|
66
|
+
const list = [
|
|
67
|
+
{ address: '1.1.1.1', health: { status: false } },
|
|
68
|
+
{ address: '2.2.2.2', health: { status: false } }
|
|
69
|
+
];
|
|
70
|
+
const out = filterUnhealthy(list);
|
|
71
|
+
assert.equal(out.length, 2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('reversedTypes maps numeric DNS types back to strings', () => {
|
|
75
|
+
assert.equal(reversedTypes.get(Packet.TYPE.A), 'A');
|
|
76
|
+
assert.equal(reversedTypes.get(Packet.TYPE.AAAA), 'AAAA');
|
|
77
|
+
assert.equal(reversedTypes.get(Packet.TYPE.MX), 'MX');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Handler integration (Redis backed)
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
const buildRequest = (name, type) => {
|
|
85
|
+
const req = new Packet({});
|
|
86
|
+
req.questions = [{ name, type: Packet.TYPE[type], class: Packet.CLASS.IN }];
|
|
87
|
+
req.source = { type: 'udp', address: '127.0.0.1', port: 5353 };
|
|
88
|
+
return req;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
test('dnsHandler answers an A query from stored records', async () => {
|
|
92
|
+
await flushTestDb();
|
|
93
|
+
await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
|
|
94
|
+
|
|
95
|
+
const response = await dnsHandler(buildRequest('example.com', 'A'));
|
|
96
|
+
const addresses = response.answers.filter(a => a.type === Packet.TYPE.A).map(a => a.address);
|
|
97
|
+
assert.ok(addresses.includes('9.8.7.6'));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('dnsHandler returns configured NS records when none are stored', async () => {
|
|
101
|
+
await flushTestDb();
|
|
102
|
+
const response = await dnsHandler(buildRequest('example.com', 'NS'));
|
|
103
|
+
const nsAnswers = response.answers.filter(a => a.type === Packet.TYPE.NS);
|
|
104
|
+
assert.ok(nsAnswers.length >= 1, 'should fall back to configured name servers');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('dnsHandler synthesises a SOA record', async () => {
|
|
108
|
+
await flushTestDb();
|
|
109
|
+
const response = await dnsHandler(buildRequest('example.com', 'SOA'));
|
|
110
|
+
const soa = response.answers.find(a => a.type === Packet.TYPE.SOA);
|
|
111
|
+
assert.ok(soa, 'a SOA record should be returned');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('dnsHandler filters unhealthy AAAA records', async () => {
|
|
115
|
+
await flushTestDb();
|
|
116
|
+
// Two AAAA records with health checks; mark one unhealthy in the health store.
|
|
117
|
+
const healthyId = await zoneStore.add('example.com', '', 'AAAA', ['2001:db8::1', 'tcp://check:1']);
|
|
118
|
+
const unhealthyId = await zoneStore.add('example.com', '', 'AAAA', ['2001:db8::2', 'tcp://check:2']);
|
|
119
|
+
assert.ok(healthyId && unhealthyId);
|
|
120
|
+
|
|
121
|
+
// Health results are keyed by "<zone-name>:<record-id>"
|
|
122
|
+
const zoneName = zoneStore.domainToName('example.com');
|
|
123
|
+
await db.redisWrite.hset('d:health:r', `${zoneName}:${unhealthyId}`, JSON.stringify({ status: false }));
|
|
124
|
+
|
|
125
|
+
const response = await dnsHandler(buildRequest('example.com', 'AAAA'));
|
|
126
|
+
const aaaa = response.answers.filter(a => a.type === Packet.TYPE.AAAA);
|
|
127
|
+
assert.equal(aaaa.length, 1, 'the unhealthy AAAA record should be filtered out');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('dnsHandler resolves CNAME chains', async () => {
|
|
131
|
+
await flushTestDb();
|
|
132
|
+
await zoneStore.add('example.com', 'alias', 'CNAME', ['example.com']);
|
|
133
|
+
await zoneStore.add('example.com', '', 'A', ['4.4.4.4']);
|
|
134
|
+
|
|
135
|
+
const response = await dnsHandler(buildRequest('alias.example.com', 'A'));
|
|
136
|
+
const types = response.answers.map(a => a.type);
|
|
137
|
+
assert.ok(types.includes(Packet.TYPE.CNAME), 'should include the CNAME answer');
|
|
138
|
+
const addresses = response.answers.filter(a => a.type === Packet.TYPE.A).map(a => a.address);
|
|
139
|
+
assert.ok(addresses.includes('4.4.4.4'), 'should follow the CNAME to the A record');
|
|
140
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { Resolver } = require('node:dns').promises;
|
|
6
|
+
|
|
7
|
+
const initDnsServer = require('../lib/dns-server');
|
|
8
|
+
const { zoneStore } = require('../lib/zone-store');
|
|
9
|
+
const { config, flushTestDb, closeDb } = require('./helpers');
|
|
10
|
+
|
|
11
|
+
let servers;
|
|
12
|
+
let resolver;
|
|
13
|
+
|
|
14
|
+
test.before(async () => {
|
|
15
|
+
await flushTestDb();
|
|
16
|
+
|
|
17
|
+
servers = await initDnsServer();
|
|
18
|
+
|
|
19
|
+
resolver = new Resolver();
|
|
20
|
+
resolver.setServers([`127.0.0.1:${config.dns.port}`]);
|
|
21
|
+
|
|
22
|
+
await zoneStore.add('example.com', '', 'A', ['9.8.7.6']);
|
|
23
|
+
await zoneStore.add('example.com', 'host', 'AAAA', ['2001:db8::1']);
|
|
24
|
+
await zoneStore.add('example.com', 'txt', 'TXT', ['hello world']);
|
|
25
|
+
// a value longer than a single 255-byte DNS character-string, but still
|
|
26
|
+
// small enough to fit in a 512-byte UDP response
|
|
27
|
+
await zoneStore.add('example.com', 'long', 'TXT', ['y'.repeat(300)]);
|
|
28
|
+
await zoneStore.add('example.com', 'mail', 'MX', ['mx.example.com', 10]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test.after(async () => {
|
|
32
|
+
if (servers) {
|
|
33
|
+
servers.udpServer.close();
|
|
34
|
+
await new Promise(resolve => servers.tcpServer.close(resolve));
|
|
35
|
+
}
|
|
36
|
+
await closeDb();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('resolves A records over the wire', async () => {
|
|
40
|
+
const addrs = await resolver.resolve4('example.com');
|
|
41
|
+
assert.deepEqual(addrs, ['9.8.7.6']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('resolves AAAA records over the wire', async () => {
|
|
45
|
+
const addrs = await resolver.resolve6('host.example.com');
|
|
46
|
+
assert.ok(addrs.includes('2001:db8::1'));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('resolves a TXT record over the wire', async () => {
|
|
50
|
+
const txt = await resolver.resolveTxt('txt.example.com');
|
|
51
|
+
const flat = txt.map(chunks => chunks.join(''));
|
|
52
|
+
assert.ok(flat.includes('hello world'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('resolves a long, multi-chunk TXT record over the wire', async () => {
|
|
56
|
+
const txt = await resolver.resolveTxt('long.example.com');
|
|
57
|
+
const flat = txt.map(chunks => chunks.join(''));
|
|
58
|
+
assert.ok(flat.includes('y'.repeat(300)));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('resolves MX records over the wire', async () => {
|
|
62
|
+
const mx = await resolver.resolveMx('mail.example.com');
|
|
63
|
+
assert.ok(mx.some(rec => rec.exchange === 'mx.example.com' && rec.priority === 10));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('returns configured NS records over the wire', async () => {
|
|
67
|
+
const ns = await resolver.resolveNs('example.com');
|
|
68
|
+
assert.ok(Array.isArray(ns) && ns.length >= 1);
|
|
69
|
+
});
|
package/test/helpers.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared helpers for the test suite. Tests must run with NODE_ENV=test so that
|
|
4
|
+
// config/test.toml points Redis at the dedicated test database (db 15).
|
|
5
|
+
|
|
6
|
+
const config = require('wild-config');
|
|
7
|
+
const db = require('../lib/db');
|
|
8
|
+
|
|
9
|
+
const isTestDatabase = () => /\/15(\?|$)/.test((config.dbs.redis || '').toString());
|
|
10
|
+
|
|
11
|
+
// Flush the dedicated test database. Refuses to run unless Redis is pointed at
|
|
12
|
+
// db 15 to avoid wiping development (db 2) or production data by accident.
|
|
13
|
+
const flushTestDb = async () => {
|
|
14
|
+
if (!isTestDatabase()) {
|
|
15
|
+
throw new Error(`Refusing to flush Redis: expected the test database (db 15) but config points at "${config.dbs.redis}". Run tests with NODE_ENV=test.`);
|
|
16
|
+
}
|
|
17
|
+
await db.redisWrite.flushdb();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Close Redis connections so the test process can exit cleanly.
|
|
21
|
+
const closeDb = async () => {
|
|
22
|
+
await Promise.allSettled([db.redisRead.quit(), db.redisWrite.quit()]);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
module.exports = { config, db, flushTestDb, closeDb, isTestDatabase };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const { initSentry } = require('../lib/sentry');
|
|
7
|
+
const logger = require('../lib/logger');
|
|
8
|
+
|
|
9
|
+
test('initSentry is a no-op when no DSN is configured', () => {
|
|
10
|
+
// Ensure the disabled path: no env DSN, and config.sentry.dsn is empty in the test config
|
|
11
|
+
delete process.env.SENTRY_DSN;
|
|
12
|
+
|
|
13
|
+
assert.doesNotThrow(() => initSentry('test'));
|
|
14
|
+
|
|
15
|
+
// error reporting stays disabled, so closeProcess() keeps owning the exit
|
|
16
|
+
assert.ok(!logger.errorReportingEnabled);
|
|
17
|
+
|
|
18
|
+
// notifyError keeps its safe no-op default from lib/logger.js
|
|
19
|
+
assert.equal(typeof logger.notifyError, 'function');
|
|
20
|
+
assert.equal(logger.notifyError(new Error('boom')), false);
|
|
21
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const { isemail, normalizeDomain } = require('../lib/tools');
|
|
7
|
+
const { closeDb } = require('./helpers');
|
|
8
|
+
|
|
9
|
+
// lib/tools transitively opens Redis connections (via cached-resolver); close
|
|
10
|
+
// them so the test process exits cleanly.
|
|
11
|
+
test.after(async () => {
|
|
12
|
+
await closeDb();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('isemail accepts valid addresses', () => {
|
|
16
|
+
assert.equal(isemail('user@example.com'), true);
|
|
17
|
+
assert.equal(isemail('first.last+tag@sub.example.org'), true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('isemail rejects invalid addresses', () => {
|
|
21
|
+
assert.equal(isemail(''), false);
|
|
22
|
+
assert.equal(isemail('not-an-email'), false);
|
|
23
|
+
assert.equal(isemail('foo@'), false);
|
|
24
|
+
assert.equal(isemail('@example.com'), false);
|
|
25
|
+
assert.equal(isemail(null), false);
|
|
26
|
+
assert.equal(isemail(undefined), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('normalizeDomain lowercases and trims', () => {
|
|
30
|
+
assert.equal(normalizeDomain(' Example.COM '), 'example.com');
|
|
31
|
+
assert.equal(normalizeDomain('WWW.Example.Com'), 'www.example.com');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('normalizeDomain decodes punycode (xn--) to unicode', () => {
|
|
35
|
+
// xn--nxasmq6b is the punycode for a Greek/test label; use a well-known one
|
|
36
|
+
// "münchen.de" -> xn--mnchen-3ya.de
|
|
37
|
+
assert.equal(normalizeDomain('xn--mnchen-3ya.de'), 'münchen.de');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('normalizeDomain is idempotent on already-unicode input', () => {
|
|
41
|
+
assert.equal(normalizeDomain('münchen.de'), 'münchen.de');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('normalizeDomain handles empty / nullish input', () => {
|
|
45
|
+
assert.equal(normalizeDomain(''), '');
|
|
46
|
+
assert.equal(normalizeDomain(null), '');
|
|
47
|
+
assert.equal(normalizeDomain(undefined), '');
|
|
48
|
+
});
|