Guided wizards, opt-in every time, and automatic DNS as the first
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.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user