A job that has a wizard now asks "Guide me / I'll do it myself" each time it starts; nothing is remembered. The shared wizard shell gives every guide a stepper, a side panel on what each step does and how to undo it, and the way forward or back. Automatic DNS, from a domain's DNS section: - finds where the domain's DNS is hosted from its SOA and NS records, and offers that host when the server can drive it; - for the major hosts, steps to create the narrowest credential, and the field named as the steps name it; - records grouped by what they do, TLSA off unless the zone is signed; - saves the provider and switches the domain over, removing the provider again if the switch fails; - watches the publishing task and public DNS, ticking each record green, and boils a host's refusal down to its distinct messages; - for hosts it can't drive, or domains not in DNS yet, every record laid out for copying, with the same live checks.
123 lines
5.3 KiB
TypeScript
123 lines
5.3 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { hostLabel, normalizeValue, parseZone, pasteParts, summarizeFailure } from './zone';
|
|
|
|
const ZONE = `mail.example.com. IN TXT "v=spf1 a -all"
|
|
example.com. IN TXT "v=spf1 mx -all"
|
|
example.com. IN MX 10 mail.example.com.
|
|
_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:[email protected]"
|
|
s1._domainkey.example.com. 300 IN TXT "v=DKIM1; k=ed25519; p=abc"
|
|
_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.
|
|
mta-sts.example.com. IN CNAME mail.example.com.
|
|
_mta-sts.example.com. IN TXT "v=STSv1; id=1"
|
|
_smtp._tls.example.com. IN TXT "v=TLSRPTv1; rua=mailto:[email protected]"
|
|
ua-auto-config.example.com. IN CNAME mail.example.com.
|
|
autoconfig.example.com. IN CNAME mail.example.com.
|
|
autodiscover.example.com. IN CNAME mail.example.com.
|
|
example.com. IN CAA 0 issue "letsencrypt.org"
|
|
_25._tcp.mail.example.com. IN TLSA 3 1 1 abcdef
|
|
; a comment
|
|
www.example.com. IN A 192.0.2.1`;
|
|
|
|
describe('parseZone', () => {
|
|
const records = parseZone(ZONE);
|
|
const kind = (name: string, type: string) => records.find((r) => r.name === name && r.type === type)?.kind;
|
|
|
|
it('sorts every record the server owns into its group', () => {
|
|
expect(kind('example.com', 'MX')).toBe('mx');
|
|
expect(kind('mail.example.com', 'TXT')).toBe('spf');
|
|
expect(kind('example.com', 'TXT')).toBe('spf');
|
|
expect(kind('_dmarc.example.com', 'TXT')).toBe('dmarc');
|
|
expect(kind('s1._domainkey.example.com', 'TXT')).toBe('dkim');
|
|
expect(kind('_jmap._tcp.example.com', 'SRV')).toBe('srv');
|
|
expect(kind('mta-sts.example.com', 'CNAME')).toBe('mtaSts');
|
|
expect(kind('_mta-sts.example.com', 'TXT')).toBe('mtaSts');
|
|
expect(kind('_smtp._tls.example.com', 'TXT')).toBe('tlsRpt');
|
|
expect(kind('ua-auto-config.example.com', 'CNAME')).toBe('autoConfig');
|
|
expect(kind('autoconfig.example.com', 'CNAME')).toBe('autoConfigLegacy');
|
|
expect(kind('autodiscover.example.com', 'CNAME')).toBe('autoDiscover');
|
|
expect(kind('example.com', 'CAA')).toBe('caa');
|
|
expect(kind('_25._tcp.mail.example.com', 'TLSA')).toBe('tlsa');
|
|
});
|
|
|
|
it('skips comments and records it cannot place', () => {
|
|
expect(records.some((r) => r.name === 'www.example.com')).toBe(false);
|
|
expect(records).toHaveLength(14);
|
|
});
|
|
|
|
it('reads past an optional TTL', () => {
|
|
expect(records.find((r) => r.kind === 'dkim')?.value).toBe('"v=DKIM1; k=ed25519; p=abc"');
|
|
});
|
|
|
|
it('copes with nothing', () => {
|
|
expect(parseZone(undefined)).toEqual([]);
|
|
expect(parseZone('')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('normalizeValue', () => {
|
|
it('joins TXT strings a resolver split up', () => {
|
|
expect(normalizeValue('TXT', '"v=DKIM1; p=ab" "cd"')).toBe('v=DKIM1; p=abcd');
|
|
expect(normalizeValue('TXT', '"v=spf1 mx -all"')).toBe(normalizeValue('TXT', '"v=spf1 mx -all"'));
|
|
});
|
|
|
|
it('ignores case and trailing dots on names', () => {
|
|
expect(normalizeValue('MX', '10 Mail.Example.com.')).toBe('10 mail.example.com');
|
|
expect(normalizeValue('CNAME', 'mail.example.com.')).toBe(normalizeValue('CNAME', 'mail.example.com'));
|
|
});
|
|
|
|
it('drops the quotes in CAA values', () => {
|
|
expect(normalizeValue('CAA', '0 issue "letsencrypt.org"')).toBe('0 issue letsencrypt.org');
|
|
});
|
|
});
|
|
|
|
describe('hostLabel', () => {
|
|
it('names records relative to their zone', () => {
|
|
expect(hostLabel('example.com', 'example.com')).toBe('@');
|
|
expect(hostLabel('_dmarc.example.com', 'example.com')).toBe('_dmarc');
|
|
expect(hostLabel('_dmarc.mail.example.com.', 'Example.com')).toBe('_dmarc.mail');
|
|
expect(hostLabel('other.org', 'example.com')).toBe('other.org');
|
|
});
|
|
});
|
|
|
|
describe('pasteParts', () => {
|
|
const rec = (type: string, value: string) => ({ name: 'example.com', type, value, kind: 'mx' as const });
|
|
it('unquotes and joins TXT', () => {
|
|
expect(pasteParts(rec('TXT', '"v=DKIM1; p=ab" "cd"')).value).toBe('v=DKIM1; p=abcd');
|
|
});
|
|
it('splits the MX priority out', () => {
|
|
expect(pasteParts(rec('MX', '10 mail.example.com.'))).toEqual({ value: 'mail.example.com', priority: '10' });
|
|
});
|
|
it('drops the trailing dot elsewhere', () => {
|
|
expect(pasteParts(rec('CNAME', 'mail.example.com.')).value).toBe('mail.example.com');
|
|
});
|
|
});
|
|
|
|
describe('summarizeFailure', () => {
|
|
const cf =
|
|
'{"success":false,"errors":[{"code":6003,"message":"Invalid request headers","error_chain":[{"code":6111,"message":"Invalid format for Authorization header"}]}],"messages":[],"result":null}';
|
|
const reason = [
|
|
`Failed to set DNS RRSet for _smtp._tls.dev.test./TXT: Failed to set DNS RRSet: API error: BadRequest ${cf}`,
|
|
`Failed to set DNS RRSet for dev.test./MX: Failed to set DNS RRSet: API error: BadRequest ${cf}`,
|
|
].join('; ');
|
|
|
|
it('keeps each provider message once, the cause first, and counts the records', () => {
|
|
expect(summarizeFailure(reason)).toEqual({
|
|
messages: ['Invalid format for Authorization header', 'Invalid request headers'],
|
|
records: 2,
|
|
});
|
|
});
|
|
|
|
it('falls back to the first error when there is no provider JSON', () => {
|
|
expect(summarizeFailure('Failed to build DNS updater: bad zone').messages).toEqual([
|
|
'Failed to build DNS updater: bad zone',
|
|
]);
|
|
expect(summarizeFailure(undefined)).toEqual({ messages: [], records: 0 });
|
|
});
|
|
});
|