pending-dns 1.3.0 → 1.4.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.
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ // Pure unit tests for the DNSSEC wire layer. No Redis or network - safe to run
4
+ // anywhere. The cross-checks against dns2's own DNSKEY encoder/decoder and the
5
+ // RFC 4509 DS vector are what guarantee our bytes match what validators expect.
6
+
7
+ const test = require('node:test');
8
+ const assert = require('node:assert/strict');
9
+ const crypto = require('crypto');
10
+ const { promisify } = require('util');
11
+
12
+ const dns2 = require('dns2');
13
+ const Packet = dns2.Packet;
14
+
15
+ const wire = require('../lib/dnssec-wire');
16
+
17
+ const generateKeyPairAsync = promisify(crypto.generateKeyPair);
18
+
19
+ test('encodeName produces canonical lowercased uncompressed labels', () => {
20
+ assert.equal(wire.encodeName('Example.COM').toString('hex'), '076578616d706c6503636f6d00');
21
+ assert.equal(wire.encodeName('').toString('hex'), '00');
22
+ assert.equal(wire.encodeName('example.com.').toString('hex'), wire.encodeName('example.com').toString('hex'));
23
+ });
24
+
25
+ test('nameLabelCount excludes root and leading wildcard', () => {
26
+ assert.equal(wire.nameLabelCount('example.com'), 2);
27
+ assert.equal(wire.nameLabelCount('www.example.com'), 3);
28
+ assert.equal(wire.nameLabelCount('*.example.com'), 2);
29
+ assert.equal(wire.nameLabelCount(''), 0);
30
+ });
31
+
32
+ test('nsecTypeBitmap encodes window blocks (RFC 4034 4.1.2)', () => {
33
+ // bits 1 (A), 46 (RRSIG), 47 (NSEC) in window 0
34
+ assert.equal(wire.nsecTypeBitmap([wire.TYPE.A, wire.TYPE.RRSIG, wire.TYPE.NSEC]).toString('hex'), '0006400000000003');
35
+ // CAA (257) lives in window 1, bit 1
36
+ assert.equal(wire.nsecTypeBitmap([wire.TYPE.CAA]).toString('hex'), '010140');
37
+ });
38
+
39
+ test('canonicalRdata matches dns2 wire RDATA for natively-encoded types', () => {
40
+ const cases = [
41
+ { type: wire.TYPE.A, rr: { address: '9.8.7.6' }, dns2: { name: 'x', type: Packet.TYPE.A, class: 1, ttl: 1, address: '9.8.7.6' } },
42
+ { type: wire.TYPE.AAAA, rr: { address: '2001:db8::1' }, dns2: { name: 'x', type: Packet.TYPE.AAAA, class: 1, ttl: 1, address: '2001:db8::1' } },
43
+ {
44
+ type: wire.TYPE.MX,
45
+ rr: { priority: 10, exchange: 'mx.example.com' },
46
+ dns2: { name: 'x', type: Packet.TYPE.MX, class: 1, ttl: 1, priority: 10, exchange: 'mx.example.com' }
47
+ },
48
+ {
49
+ type: wire.TYPE.TXT,
50
+ rr: { data: ['hello', 'world'] },
51
+ dns2: { name: 'x', type: Packet.TYPE.TXT, class: 1, ttl: 1, data: ['hello', 'world'] }
52
+ },
53
+ {
54
+ type: wire.TYPE.CAA,
55
+ rr: { flags: 0, tag: 'issue', value: 'letsencrypt.org' },
56
+ dns2: { name: 'x', type: Packet.TYPE.CAA, class: 1, ttl: 1, flags: 0, tag: 'issue', value: 'letsencrypt.org' }
57
+ }
58
+ ];
59
+
60
+ for (const c of cases) {
61
+ // Extract dns2's RDATA by encoding a full resource and reparsing it.
62
+ const buf = Packet.Resource.encode(c.dns2);
63
+ const reparsed = Packet.Resource.parse(new Packet.Reader(buf));
64
+ const reEncoded = Packet.Resource.encode(reparsed);
65
+ // RDATA is the tail after name(uncompressed for a standalone record)+type(2)+class(2)+ttl(4)+rdlen(2).
66
+ const nameLen = wire.encodeName(c.dns2.name).length;
67
+ const dns2Rdata = reEncoded.slice(nameLen + 10);
68
+ assert.equal(wire.canonicalRdata(c.type, c.rr).toString('hex'), dns2Rdata.toString('hex'), `type ${c.type}`);
69
+ }
70
+ });
71
+
72
+ test('encodeDNSKEYRdata matches dns2 DNSKEY encoder byte-for-byte', async () => {
73
+ const { publicKey } = await generateKeyPairAsync('ec', { namedCurve: 'prime256v1' });
74
+ const jwk = publicKey.export({ format: 'jwk' });
75
+ const pubkey = wire.ALGS[13].pubkeyFromJwk(jwk);
76
+
77
+ const flags = wire.DNSKEY_FLAGS_CSK;
78
+ const mine = wire.encodeDNSKEYRdata({ flags, protocol: wire.DNSKEY_PROTOCOL, algorithm: 13, pubkey });
79
+
80
+ const writer = new Packet.Writer();
81
+ Packet.Resource.DNSKEY.encode({ flags, protocol: wire.DNSKEY_PROTOCOL, algorithm: 13, key: pubkey.toString('base64') }, writer);
82
+ const dns2Rdata = writer.toBuffer();
83
+
84
+ assert.ok(mine.equals(dns2Rdata), 'DNSKEY RDATA must match dns2 output');
85
+ });
86
+
87
+ test('dnskeyKeyTag agrees with dns2 decoder', async () => {
88
+ const { publicKey } = await generateKeyPairAsync('ed25519');
89
+ const jwk = publicKey.export({ format: 'jwk' });
90
+ const pubkey = wire.ALGS[15].pubkeyFromJwk(jwk);
91
+ const rdata = wire.encodeDNSKEYRdata({ flags: 257, protocol: 3, algorithm: 15, pubkey });
92
+
93
+ const decoded = Packet.Resource.DNSKEY.decode.call({}, new Packet.Reader(rdata), rdata.length);
94
+ assert.equal(wire.dnskeyKeyTag(rdata), decoded.keyTag);
95
+ });
96
+
97
+ test('dsDigest and dnskeyKeyTag match the RFC 4509 example', () => {
98
+ // dskey.example.com. DNSKEY 256 3 5 (...) -> DS 60485 5 2 D4B7...
99
+ const pubkey = Buffer.from(
100
+ 'AQOeiiR0GOMYkDshWoSKz9XzfwJr1AYtsmx3TGkJaNXVbfi/2pHm822aJ5iI9BMzNXxeYCmZDRD99WYwYqUSdjMmmAphXdvxegXd/M5+X7OrzKBaMbCVdFLUUh6DhweJBjEVv5f2wwjM9XzcnOf+EPbtG9DMBmADjFDc2w/rljwvFw==',
101
+ 'base64'
102
+ );
103
+ const rdata = wire.encodeDNSKEYRdata({ flags: 256, protocol: 3, algorithm: 5, pubkey });
104
+
105
+ assert.equal(wire.dnskeyKeyTag(rdata), 60485);
106
+ assert.equal(
107
+ wire.dsDigest('dskey.example.com', rdata, 2).toString('hex').toUpperCase(),
108
+ 'D4B7D520E7BB5F0F67674A0CCEB1E3E0614B93C4F9E99B8383F6A1E4469DA50A'
109
+ );
110
+ });
111
+
112
+ test('encodeTLSARdata lays out usage/selector/matchingType/cert', () => {
113
+ const rdata = wire.encodeTLSARdata({ usage: 3, selector: 1, matchingType: 1, certificate: 'abcd' });
114
+ assert.equal(rdata.toString('hex'), '030101abcd');
115
+ });
116
+
117
+ test('encodeTLSARdata rejects empty, odd-length, and out-of-range input', () => {
118
+ // empty certificate would otherwise emit a TLSA with no association data
119
+ assert.throws(() => wire.encodeTLSARdata({ usage: 3, selector: 1, matchingType: 1, certificate: '' }), /non-empty even-length hex/);
120
+ assert.throws(() => wire.encodeTLSARdata({ usage: 3, selector: 1, matchingType: 1, certificate: 'abc' }), /even-length hex/);
121
+ // u8() would otherwise wrap these silently (256 -> 0)
122
+ assert.throws(() => wire.encodeTLSARdata({ usage: 256, selector: 1, matchingType: 1, certificate: 'abcd' }), /0-255/);
123
+ assert.throws(() => wire.encodeTLSARdata({ usage: 3, selector: -1, matchingType: 1, certificate: 'abcd' }), /0-255/);
124
+ });
125
+
126
+ // RRSIG signing round-trips: prove the canonical signing input + per-algorithm
127
+ // crypto calls are internally consistent (the CI-grade DNSSEC guarantee).
128
+ for (const [algId, keygen] of [
129
+ [13, ['ec', { namedCurve: 'prime256v1' }]],
130
+ [15, ['ed25519', {}]],
131
+ [8, ['rsa', { modulusLength: 2048, publicExponent: 65537 }]]
132
+ ]) {
133
+ test(`RRSIG over an A RRset verifies for algorithm ${algId} (${wire.ALGS[algId].name})`, async () => {
134
+ const { publicKey, privateKey } = await generateKeyPairAsync(keygen[0], keygen[1]);
135
+
136
+ const rrset = [
137
+ { name: 'example.com', address: '1.2.3.4' },
138
+ { name: 'example.com', address: '5.6.7.8' }
139
+ ]
140
+ .map(rr => wire.canonicalRdata(wire.TYPE.A, rr))
141
+ .sort(wire.compareCanonicalRdata)
142
+ .map(rdata => wire.canonicalRR('example.com', wire.TYPE.A, 1, 300, rdata));
143
+
144
+ const preimage = wire.encodeRRSIGSigningPreimage({
145
+ typeCovered: wire.TYPE.A,
146
+ algorithm: algId,
147
+ labels: wire.nameLabelCount('example.com'),
148
+ originalTtl: 300,
149
+ expiration: 2000000000,
150
+ inception: 1000000000,
151
+ keyTag: 12345,
152
+ signerName: 'example.com'
153
+ });
154
+
155
+ const tbs = Buffer.concat([preimage, ...rrset]);
156
+ const signature = wire.ALGS[algId].sign(tbs, privateKey);
157
+ assert.ok(wire.ALGS[algId].verify(tbs, publicKey, signature), 'signature must verify');
158
+
159
+ // A tampered RRset must fail.
160
+ const tampered = Buffer.concat([preimage, ...rrset.slice(0, 1)]);
161
+ assert.equal(wire.ALGS[algId].verify(tampered, publicKey, signature), false);
162
+ });
163
+ }
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+
3
+ // Key-management and signing tests for lib/dnssec.js. Backed by Redis (db 15).
4
+
5
+ const test = require('node:test');
6
+ const assert = require('node:assert/strict');
7
+ const crypto = require('crypto');
8
+
9
+ const dnssec = require('../lib/dnssec');
10
+ const wire = require('../lib/dnssec-wire');
11
+ const db = require('../lib/db');
12
+ const { config, flushTestDb, closeDb } = require('./helpers');
13
+
14
+ test.after(async () => {
15
+ await closeDb();
16
+ });
17
+
18
+ // Parse an RRSIG RDATA buffer back into its fields + signature.
19
+ const parseRRSIG = data => {
20
+ let off = 18;
21
+ while (data[off] !== 0) {
22
+ off += 1 + data[off];
23
+ }
24
+ off += 1; // include the root label
25
+ return {
26
+ typeCovered: data.readUInt16BE(0),
27
+ algorithm: data.readUInt8(2),
28
+ labels: data.readUInt8(3),
29
+ originalTtl: data.readUInt32BE(4),
30
+ expiration: data.readUInt32BE(8),
31
+ inception: data.readUInt32BE(12),
32
+ keyTag: data.readUInt16BE(16),
33
+ preimage: data.slice(0, off),
34
+ signature: data.slice(off)
35
+ };
36
+ };
37
+
38
+ test('enableZone generates a CSK and reports consistent DS/DNSKEY', async () => {
39
+ await flushTestDb();
40
+ const status = await dnssec.enableZone('example.com', { algorithm: 13 });
41
+
42
+ assert.equal(status.enabled, true);
43
+ assert.equal(status.algorithm, 13);
44
+ assert.equal(status.ds.length, 1);
45
+ assert.equal(status.dnskey.length, 1);
46
+ assert.equal(status.ds[0].keyTag, status.dnskey[0].keyTag);
47
+
48
+ // The DS digest must recompute from the published DNSKEY.
49
+ const dnskeyRdata = wire.encodeDNSKEYRdata({
50
+ flags: status.dnskey[0].flags,
51
+ protocol: status.dnskey[0].protocol,
52
+ algorithm: status.dnskey[0].algorithm,
53
+ pubkey: Buffer.from(status.dnskey[0].publicKey, 'base64')
54
+ });
55
+ assert.equal(wire.dnskeyKeyTag(dnskeyRdata), status.ds[0].keyTag);
56
+ assert.equal(wire.dsDigest('example.com', dnskeyRdata, status.ds[0].digestType).toString('hex'), status.ds[0].digest);
57
+ });
58
+
59
+ test('enableZone is idempotent and keeps the same key', async () => {
60
+ await flushTestDb();
61
+ const first = await dnssec.enableZone('example.com', { algorithm: 13 });
62
+ const second = await dnssec.enableZone('example.com', { algorithm: 13 });
63
+ assert.equal(first.dnskey[0].keyTag, second.dnskey[0].keyTag);
64
+ assert.equal(first.dnskey[0].publicKey, second.dnskey[0].publicKey);
65
+ });
66
+
67
+ test('isZoneSigned reflects enable/disable', async () => {
68
+ await flushTestDb();
69
+ assert.equal(await dnssec.isZoneSigned('example.com'), false);
70
+ await dnssec.enableZone('example.com', { algorithm: 13 });
71
+ assert.equal(await dnssec.isZoneSigned('example.com'), true);
72
+ await dnssec.disableZone('example.com');
73
+ assert.equal(await dnssec.isZoneSigned('example.com'), false);
74
+ });
75
+
76
+ for (const algorithm of [13, 15, 8]) {
77
+ test(`signRRset produces a verifiable RRSIG for algorithm ${algorithm}`, async () => {
78
+ await flushTestDb();
79
+ await dnssec.enableZone('example.com', { algorithm });
80
+ const signer = await dnssec.getSigner('example.com');
81
+ assert.ok(signer, 'signer should be available');
82
+ assert.equal(signer.keys.length, 1, 'a single-algorithm zone has one signing key');
83
+ const key = signer.keys[0];
84
+
85
+ const rrs = [
86
+ { name: 'example.com', type: wire.TYPE.A, class: 1, ttl: 300, address: '1.2.3.4' },
87
+ { name: 'example.com', type: wire.TYPE.A, class: 1, ttl: 300, address: '5.6.7.8' }
88
+ ];
89
+ // signRRset returns one RRSIG per signing key (one per algorithm).
90
+ const rrsigs = dnssec.signRRset(signer, 'example.com', 'example.com', wire.TYPE.A, 300, rrs);
91
+ assert.equal(rrsigs.length, 1, 'one RRSIG per signing key');
92
+ const rrsig = rrsigs[0];
93
+
94
+ assert.equal(rrsig.type, wire.TYPE.RRSIG);
95
+ const parsed = parseRRSIG(rrsig.data);
96
+ assert.equal(parsed.typeCovered, wire.TYPE.A);
97
+ assert.equal(parsed.algorithm, algorithm);
98
+ assert.equal(parsed.keyTag, key.keyTag);
99
+ assert.ok(parsed.inception < parsed.expiration);
100
+
101
+ // Rebuild the signed bytes and verify with the public half of the key.
102
+ const canonical = rrs
103
+ .map(rr => wire.canonicalRdata(wire.TYPE.A, rr))
104
+ .sort(wire.compareCanonicalRdata)
105
+ .map(rdata => wire.canonicalRR('example.com', wire.TYPE.A, 1, 300, rdata));
106
+ const tbs = Buffer.concat([parsed.preimage, ...canonical]);
107
+ const publicKey = crypto.createPublicKey(key.privateKeyObj);
108
+ assert.ok(wire.ALGS[algorithm].verify(tbs, publicKey, parsed.signature), 'RRSIG must verify');
109
+ });
110
+ }
111
+
112
+ test('enableZone rolls to a new algorithm and removeKey finishes the rollover', async () => {
113
+ await flushTestDb();
114
+ const first = await dnssec.enableZone('example.com', { algorithm: 13 });
115
+ assert.equal(first.dnskey.length, 1);
116
+ const oldKeyTag = first.keyTag;
117
+
118
+ // Re-enable with a different algorithm -> rollover: both keys are kept and
119
+ // the zone is signed with both algorithms (RFC 6840 5.11).
120
+ const rolled = await dnssec.enableZone('example.com', { algorithm: 15 });
121
+ assert.equal(rolled.algorithm, 15, 'active algorithm switches to the new one');
122
+ assert.equal(rolled.dnskey.length, 2, 'both keys are published during overlap');
123
+ assert.notEqual(rolled.keyTag, oldKeyTag, 'a new active key is generated');
124
+ const newKeyTag = rolled.keyTag;
125
+
126
+ const signer = await dnssec.getSigner('example.com');
127
+ assert.deepEqual(
128
+ signer.keys.map(k => k.algorithm).sort((a, b) => a - b),
129
+ [13, 15],
130
+ 'signs with both algorithms during the rollover'
131
+ );
132
+
133
+ // The active key cannot be removed - roll away from it first.
134
+ await assert.rejects(() => dnssec.removeKey('example.com', newKeyTag), /active key/);
135
+
136
+ // Remove the old key to finish the rollover.
137
+ assert.equal(await dnssec.removeKey('example.com', oldKeyTag), true);
138
+ const after = await dnssec.getZoneStatus('example.com');
139
+ assert.equal(after.dnskey.length, 1);
140
+ assert.equal(after.keyTag, newKeyTag);
141
+
142
+ const signerAfter = await dnssec.getSigner('example.com');
143
+ assert.deepEqual(
144
+ signerAfter.keys.map(k => k.algorithm),
145
+ [15]
146
+ );
147
+
148
+ // The last remaining key cannot be removed.
149
+ await assert.rejects(() => dnssec.removeKey('example.com', newKeyTag), /last remaining key/);
150
+ });
151
+
152
+ test('re-enabling with no algorithm preserves the rolled-to active key', async () => {
153
+ await flushTestDb();
154
+ await dnssec.enableZone('example.com', { algorithm: 13 });
155
+ const rolled = await dnssec.enableZone('example.com', { algorithm: 15 });
156
+ assert.equal(rolled.algorithm, 15);
157
+
158
+ // Empty body (no algorithm) must NOT revert the active key to the config default.
159
+ const reenabled = await dnssec.enableZone('example.com');
160
+ assert.equal(reenabled.algorithm, 15, 'active stays on the rolled-to algorithm');
161
+ assert.equal(reenabled.keyTag, rolled.keyTag, 'active key is unchanged');
162
+ });
163
+
164
+ test('enableZone refuses when the global switch is off', async () => {
165
+ await flushTestDb();
166
+ config.dnssec.enabled = false;
167
+ try {
168
+ await assert.rejects(() => dnssec.enableZone('example.com', { algorithm: 13 }), /disabled globally/);
169
+ } finally {
170
+ config.dnssec.enabled = true;
171
+ }
172
+ });
173
+
174
+ test('getSigner caches the signer for the configured TTL', async () => {
175
+ await flushTestDb();
176
+ await dnssec.enableZone('example.com', { algorithm: 13 });
177
+ config.dnssec.signerCacheTtl = 30;
178
+ try {
179
+ assert.ok(await dnssec.getSigner('example.com'), 'signer is built and cached');
180
+ // Disable via raw Redis, bypassing invalidateSigner: the cache must keep
181
+ // serving the signer until the TTL expires.
182
+ await db.redisWrite.hset(`d:dnssec:${dnssec.testables.zoneName('example.com')}`, 'enabled', '0');
183
+ assert.ok(await dnssec.getSigner('example.com'), 'still served from cache despite the raw disable');
184
+ } finally {
185
+ config.dnssec.signerCacheTtl = 0;
186
+ }
187
+ });
188
+
189
+ test('a configured inception skew and signature validity of 0 are honored', async () => {
190
+ await flushTestDb();
191
+ await dnssec.enableZone('example.com', { algorithm: 13 });
192
+ const signer = await dnssec.getSigner('example.com');
193
+
194
+ const origSkew = config.dnssec.inceptionSkew;
195
+ const origValidity = config.dnssec.signatureValidity;
196
+ config.dnssec.inceptionSkew = 0;
197
+ config.dnssec.signatureValidity = 0;
198
+ try {
199
+ const before = Math.floor(Date.now() / 1000);
200
+ const rrsigs = dnssec.signRRset(signer, 'example.com', 'example.com', wire.TYPE.A, 300, [
201
+ { name: 'example.com', type: wire.TYPE.A, class: 1, ttl: 300, address: '1.2.3.4' }
202
+ ]);
203
+ const after = Math.floor(Date.now() / 1000);
204
+ const parsed = parseRRSIG(rrsigs[0].data);
205
+ // inceptionSkew = 0 means no backdating: inception is "now", not now - 3600.
206
+ assert.ok(parsed.inception >= before && parsed.inception <= after, 'inception is now, not backdated');
207
+ // signatureValidity = 0 means expiration equals inception, not now + 604800.
208
+ assert.equal(parsed.expiration, parsed.inception, 'validity 0 yields expiration equal to inception');
209
+ } finally {
210
+ config.dnssec.inceptionSkew = origSkew;
211
+ config.dnssec.signatureValidity = origValidity;
212
+ }
213
+ });
package/test/helpers.js CHANGED
@@ -12,7 +12,9 @@ const isTestDatabase = () => /\/15(\?|$)/.test((config.dbs.redis || '').toString
12
12
  // db 15 to avoid wiping development (db 2) or production data by accident.
13
13
  const flushTestDb = async () => {
14
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.`);
15
+ throw new Error(
16
+ `Refusing to flush Redis: expected the test database (db 15) but config points at "${config.dbs.redis}". Run tests with NODE_ENV=test.`
17
+ );
16
18
  }
17
19
  await db.redisWrite.flushdb();
18
20
  };
@@ -19,10 +19,46 @@ test.beforeEach(async () => {
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
21
  test('module exports the allowed record types and CAA tags', () => {
22
- assert.deepEqual(allowedTypes, ['A', 'AAAA', 'ANAME', 'CNAME', 'MX', 'TXT', 'CAA', 'URL', 'NS']);
22
+ assert.deepEqual(allowedTypes, ['A', 'AAAA', 'ANAME', 'CNAME', 'MX', 'TXT', 'CAA', 'TLSA', 'URL', 'NS']);
23
23
  assert.deepEqual(allowedTags, ['issue', 'issuewild', 'iodef']);
24
24
  });
25
25
 
26
+ test('add rejects a TLSA record whose certificate is not even-length hex', async () => {
27
+ assert.equal(await zoneStore.add('example.com', '_25._tcp', 'TLSA', [3, 1, 1, 'abc']), false, 'odd-length hex');
28
+ assert.equal(await zoneStore.add('example.com', '_25._tcp', 'TLSA', [3, 1, 1, 'xyz0']), false, 'non-hex');
29
+ });
30
+
31
+ test('add stores a TLSA record with even-length hex', async () => {
32
+ assert.ok(await zoneStore.add('example.com', '_25._tcp', 'TLSA', [3, 1, 1, 'aabb']), 'even-length hex is accepted');
33
+ });
34
+
35
+ test('update rejects a non-even-length-hex TLSA on the unchanged name/type path', async () => {
36
+ const id = await zoneStore.add('example.com', '_25._tcp', 'TLSA', [3, 1, 1, 'aabb']);
37
+ assert.ok(id, 'baseline TLSA stored');
38
+
39
+ // Same subdomain + type (the direct-hset path, not the delete+add path) with an
40
+ // odd-length cert must be refused, mirroring the guard in add().
41
+ const res = await zoneStore.update('example.com', id, '_25._tcp', 'TLSA', [3, 1, 1, 'abc']);
42
+ assert.equal(res, false, 'odd-length hex is rejected on the update unchanged path');
43
+
44
+ const tlsa = (await zoneStore.list('example.com')).find(r => r.type === 'TLSA');
45
+ assert.ok(tlsa, 'the TLSA record still exists');
46
+ assert.equal(tlsa.value[3], 'aabb', 'the original certificate is preserved');
47
+ });
48
+
49
+ test('existingTypes folds in single-level wildcard types when asked', async () => {
50
+ await zoneStore.add('example.com', '*', 'A', ['1.2.3.4']);
51
+ await zoneStore.add('example.com', 'foo', 'TXT', ['hi']);
52
+
53
+ const union = await zoneStore.existingTypes('foo.example.com', true);
54
+ assert.ok(union.includes('A'), 'wildcard-supplied A is included');
55
+ assert.ok(union.includes('TXT'), 'exact TXT at the name is included');
56
+ assert.ok(!union.includes('AAAA'), 'AAAA is supplied by neither');
57
+
58
+ const exact = await zoneStore.existingTypes('foo.example.com');
59
+ assert.ok(exact.includes('TXT') && !exact.includes('A'), 'exact-only excludes the wildcard A');
60
+ });
61
+
26
62
  test('getFullId / parseFullId round-trip', () => {
27
63
  const id = zoneStore.getFullId('com.example.www', 'CNAME', 'abc123');
28
64
  const parsed = zoneStore.parseFullId(id);