Tests: add real-world config coverage and prune brittle cases

Reduce presentation-focused test churn while adding corpus-based parser, validator, capability-guard, and round-trip save coverage to better protect future refactors.
This commit is contained in:
2026-03-11 11:44:01 +10:00
parent e33402467f
commit 3ca935e551
33 changed files with 1139 additions and 149 deletions
@@ -0,0 +1,100 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>
/// Sample config line per option for consistent parser coverage. When adding new options to the app,
/// add an entry here so the option-coverage test continues to exercise every option.
/// </summary>
public static class OptionCoverageData
{
/// <summary>Option key and a minimal valid config line (without newline).</summary>
public static IReadOnlyList<(string OptionKey, string ConfigLine)> GetParserCoverageEntries()
{
return new (string, string)[]
{
// LastWins
(DnsmasqConfKeys.Port, "port=53"),
(DnsmasqConfKeys.CacheSize, "cache-size=500"),
(DnsmasqConfKeys.LocalTtl, "local-ttl=300"),
(DnsmasqConfKeys.MxTarget, "mx-target=mail.example.com"),
(DnsmasqConfKeys.DhcpLeasefile, "dhcp-leasefile=/var/lib/dnsmasq.leases"),
(DnsmasqConfKeys.DhcpLeaseMax, "dhcp-lease-max=1000"),
(DnsmasqConfKeys.Hostsdir, "hostsdir=/etc/dnsmasq.d"),
(DnsmasqConfKeys.PidFile, "pid-file=/run/dnsmasq.pid"),
(DnsmasqConfKeys.User, "user=nobody"),
(DnsmasqConfKeys.TftpRoot, "tftp-root=/var/lib/tftpboot"),
// Flag
(DnsmasqConfKeys.NoHosts, "no-hosts"),
(DnsmasqConfKeys.ExpandHosts, "expand-hosts"),
(DnsmasqConfKeys.BogusPriv, "bogus-priv"),
(DnsmasqConfKeys.NoResolv, "no-resolv"),
(DnsmasqConfKeys.DomainNeeded, "domain-needed"),
(DnsmasqConfKeys.StrictOrder, "strict-order"),
(DnsmasqConfKeys.AllServers, "all-servers"),
(DnsmasqConfKeys.BindInterfaces, "bind-interfaces"),
(DnsmasqConfKeys.DhcpAuthoritative, "dhcp-authoritative"),
(DnsmasqConfKeys.EnableTftp, "enable-tftp"),
(DnsmasqConfKeys.Dnssec, "dnssec"),
(DnsmasqConfKeys.Conntrack, "conntrack"),
(DnsmasqConfKeys.Do0x20Encode, "do-0x20-encode"),
(DnsmasqConfKeys.No0x20Encode, "no-0x20-encode"),
// Multi - resolver / DNS
(DnsmasqConfKeys.Server, "server=1.1.1.1"),
(DnsmasqConfKeys.Local, "local=/lan/"),
(DnsmasqConfKeys.RevServer, "rev-server=192.168.1.0/24,10.0.0.1"),
(DnsmasqConfKeys.Address, "address=/example.com/127.0.0.1"),
(DnsmasqConfKeys.Domain, "domain=home.lan,192.168.1.0/24"),
(DnsmasqConfKeys.Cname, "cname=www.example.com,example.com"),
(DnsmasqConfKeys.MxHost, "mx-host=example.com,mail.example.com,10"),
(DnsmasqConfKeys.Srv, "srv-host=_http._tcp.example.com,host.example.com,80"),
(DnsmasqConfKeys.PtrRecord, "ptr-record=1.168.192.in-addr.arpa,router.lan"),
(DnsmasqConfKeys.TxtRecord, "txt-record=example.com,text"),
(DnsmasqConfKeys.NaptrRecord, "naptr-record=example.com,0,0,a,s,r"),
(DnsmasqConfKeys.DnsRr, "dns-rr=example.com,16,01:02"),
(DnsmasqConfKeys.HostRecord, "host-record=host.example.com,1.2.3.4"),
(DnsmasqConfKeys.DynamicHost, "dynamic-host=example.com"),
(DnsmasqConfKeys.CaaRecord, "caa-record=example.com,0,issue,letsencrypt.org"),
(DnsmasqConfKeys.RebindDomainOk, "rebind-domain-ok=example.com"),
(DnsmasqConfKeys.BogusNxdomain, "bogus-nxdomain=192.168.1.1"),
(DnsmasqConfKeys.IgnoreAddress, "ignore-address=192.168.1.1"),
(DnsmasqConfKeys.Alias, "alias=0.0.0.0,example.com"),
(DnsmasqConfKeys.FilterRr, "filter-rr=0.0.0.0,A"),
(DnsmasqConfKeys.CacheRr, "cache-rr=0.0.0.0,A"),
(DnsmasqConfKeys.Ipset, "ipset=/example.com/set1"),
(DnsmasqConfKeys.Nftset, "nftset=/example.com/table/set"),
(DnsmasqConfKeys.ConnmarkAllowlist, "connmark-allowlist=0xff,example.com"),
// Multi - auth / synth
(DnsmasqConfKeys.AuthServer, "auth-server=zone.example.com,eth0"),
(DnsmasqConfKeys.SynthDomain, "synth-domain=dynamic.example.com,192.168.2.1,192.168.2.100"),
(DnsmasqConfKeys.AuthZone, "auth-zone=example.com"),
(DnsmasqConfKeys.TrustAnchor, "trust-anchor=.,20326,8,2,E4F1769B8A0E3142E125223716F878F0A3B2C3D4"),
// Multi - DHCP
(DnsmasqConfKeys.DhcpRange, "dhcp-range=192.168.1.50,192.168.1.150,12h"),
(DnsmasqConfKeys.DhcpHost, "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10"),
(DnsmasqConfKeys.DhcpOption, "dhcp-option=3,192.168.1.1"),
(DnsmasqConfKeys.DhcpMatch, "dhcp-match=set:efi,option:client-arch,6"),
(DnsmasqConfKeys.DhcpMac, "dhcp-mac=set:vendor,aa:bb:cc:dd:ee:ff"),
(DnsmasqConfKeys.DhcpNameMatch, "dhcp-name-match=set:name,hostname*"),
(DnsmasqConfKeys.DhcpIgnoreNames, "dhcp-ignore-names=tag:guest"),
(DnsmasqConfKeys.DhcpBoot, "dhcp-boot=pxelinux.0"),
(DnsmasqConfKeys.Leasequery, "leasequery=10.0.0.0/24"),
(DnsmasqConfKeys.RaParam, "ra-param=eth0,high"),
(DnsmasqConfKeys.Slaac, "slaac=eth0"),
(DnsmasqConfKeys.PxeService, "pxe-service=x86PC,Install Linux"),
(DnsmasqConfKeys.DhcpRelay, "dhcp-relay=192.168.1.1,eth0"),
(DnsmasqConfKeys.DhcpCircuitid, "dhcp-circuitid=eth0,010203"),
(DnsmasqConfKeys.DhcpRemoteid, "dhcp-remoteid=010203"),
(DnsmasqConfKeys.DhcpSubscrid, "dhcp-subscrid=010203"),
(DnsmasqConfKeys.TagIf, "tag-if=set:tag,eth0"),
(DnsmasqConfKeys.SharedNetwork, "shared-network=name,192.168.1.0/24"),
(DnsmasqConfKeys.DhcpOptionPxe, "dhcp-option-pxe=set:known,1"),
(DnsmasqConfKeys.DhcpVendorclass, "dhcp-vendorclass=set:pxe,PXEClient"),
(DnsmasqConfKeys.DhcpUserclass, "dhcp-userclass=set:efi,EFI"),
(DnsmasqConfKeys.AddnHosts, "addn-hosts=/etc/hosts.extra"),
(DnsmasqConfKeys.Interface, "interface=eth0"),
(DnsmasqConfKeys.ListenAddress, "listen-address=127.0.0.1"),
(DnsmasqConfKeys.ResolvFile, "resolv-file=/etc/resolv.dnsmasq"),
};
}
}
@@ -0,0 +1,51 @@
using System.Text.Json;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>Expected parser/effective state for a real-world corpus case. Counts and booleans only.</summary>
public sealed class CorpusExpected
{
public int? ServerCountMin { get; set; }
public int? LocalCountMin { get; set; }
public int? AddressCountMin { get; set; }
public int? CacheSize { get; set; }
public int? AddnHostsCountMin { get; set; }
public int? DomainCountMin { get; set; }
public bool? NoHosts { get; set; }
public bool? NoResolv { get; set; }
public bool? BogusPriv { get; set; }
public string? Do0x20State { get; set; }
public int? Port { get; set; }
public bool? ExpandHosts { get; set; }
public int? CnameCountMin { get; set; }
public int? TxtRecordCountMin { get; set; }
public int? SrvCountMin { get; set; }
public int? PtrRecordCountMin { get; set; }
public int? MxHostCountMin { get; set; }
public int? AuthZoneCountMin { get; set; }
public int? SynthDomainCountMin { get; set; }
public int? AuthServerCountMin { get; set; }
}
/// <summary>Single real-world corpus case from dnsmasq-real-cases.json.</summary>
public sealed class CorpusCase
{
public string File { get; set; } = "";
public CorpusExpected Expected { get; set; } = new();
public List<string> SemanticInvalidOptions { get; set; } = [];
}
/// <summary>Loads real-world corpus index and resolves paths via TestDataHelper.</summary>
public static class RealWorldCasesHelper
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
public static IReadOnlyList<CorpusCase> LoadCases()
{
var path = TestDataHelper.GetPath("real-world/dnsmasq-real-cases.json");
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<List<CorpusCase>>(json, JsonOptions) ?? [];
}
public static string Resolve(CorpusCase c) => TestDataHelper.GetPath(c.File);
}
@@ -0,0 +1,50 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>Ensures corpus index and files stay in sync; fails fast when testdata drifts.</summary>
public class RealWorldCorpusIntegrityTests
{
[Fact]
public void EveryCorpusCase_PointsToExistingFile()
{
var cases = RealWorldCasesHelper.LoadCases();
Assert.NotEmpty(cases);
foreach (var c in cases)
{
var path = RealWorldCasesHelper.Resolve(c);
Assert.True(File.Exists(path), $"Corpus file missing: {c.File}");
}
}
[Fact]
public void EveryBadCase_DeclaresSemanticInvalidOptions()
{
var cases = RealWorldCasesHelper.LoadCases();
var badFiles = new[] { "bad/", "real-world/bad/" };
foreach (var c in cases)
{
var isBad = badFiles.Any(p => c.File.Contains(p, StringComparison.Ordinal));
if (isBad)
Assert.NotEmpty(c.SemanticInvalidOptions);
}
}
[Fact]
public void SemanticInvalidOptions_ArePresentInParsedValues()
{
foreach (var c in RealWorldCasesHelper.LoadCases())
{
if (c.SemanticInvalidOptions.Count == 0)
continue;
var mainPath = RealWorldCasesHelper.Resolve(c);
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
foreach (var option in c.SemanticInvalidOptions)
{
var values = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, option);
Assert.NotEmpty(values);
}
}
}
}
@@ -1,59 +0,0 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Models;
/// <summary>
/// Tests for <see cref="ConfigValueSource"/> and <see cref="ConfigValueSource.GetReadOnlyTooltip"/>.
/// </summary>
public class ConfigValueSourceTests
{
[Fact]
public void GetReadOnlyTooltip_WhenManaged_ReturnsNull()
{
var source = new ConfigValueSource("/data/zz-managed.conf", "zz-managed.conf", IsManaged: true, LineNumber: 5);
Assert.Null(source.GetReadOnlyTooltip());
}
[Fact]
public void GetReadOnlyTooltip_WhenReadOnlyWithoutLineNumber_ReturnsFileNameOnly()
{
var fileName = "02.conf";
var fullPath = $"/etc/dnsmasq.d/{fileName}";
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: null);
var tooltip = source.GetReadOnlyTooltip();
Assert.NotNull(tooltip);
Assert.Contains(fileName, tooltip);
Assert.Contains("readonly", tooltip);
Assert.Contains(fullPath, tooltip);
Assert.DoesNotContain("line", tooltip);
}
[Fact]
public void GetReadOnlyTooltip_WhenReadOnlyWithLineNumber_IncludesLineNumber()
{
var fileName = "02.conf";
var fullPath = $"/etc/dnsmasq.d/{fileName}";
int? lineNumber = 3;
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: lineNumber);
var tooltip = source.GetReadOnlyTooltip();
Assert.NotNull(tooltip);
Assert.Contains(fileName, tooltip);
Assert.Contains($"line {lineNumber}", tooltip);
Assert.Contains("readonly", tooltip);
Assert.Contains(fullPath, tooltip);
}
[Fact]
public void IsReadOnly_WhenManaged_IsFalse()
{
var source = new ConfigValueSource("/data/zz.conf", "zz.conf", IsManaged: true);
Assert.False(source.IsReadOnly);
}
[Fact]
public void IsReadOnly_WhenNotManaged_IsTrue()
{
var source = new ConfigValueSource("/etc/dnsmasq.conf", "dnsmasq.conf", IsManaged: false);
Assert.True(source.IsReadOnly);
}
}
@@ -80,32 +80,6 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.LogDhcp));
}
[Fact]
public void OfficialExample_FileContainsExpectedOptionNames()
{
var path = GetOfficialExamplePath();
var content = File.ReadAllText(path);
// Sanity: official example documents the options we support (as commented lines).
Assert.Contains("read-ethers", content);
Assert.Contains("dhcp-option-force", content);
Assert.Contains("dhcp-rapid-commit", content);
Assert.Contains("dhcp-script", content);
Assert.Contains("tftp-no-fail", content);
Assert.Contains("tftp-no-blocksize", content);
Assert.Contains("mx-target", content);
Assert.Contains("localmx", content);
Assert.Contains("selfmx", content);
Assert.Contains("enable-ra", content);
Assert.Contains("log-dhcp", content);
Assert.Contains("ipset=", content);
Assert.Contains("nftset=", content);
Assert.Contains("dhcp-mac", content);
Assert.Contains("dhcp-name-match", content);
Assert.Contains("dhcp-ignore-names", content);
// SRV records use option name srv-host (not "srv")
Assert.Contains("srv-host", content);
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
{
@@ -124,4 +98,23 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
Assert.False(noHosts);
}
/// <summary>Official-like sample with commented and active lines: commented ignored, active parsed, state reflects active only.</summary>
[Fact]
public void OfficialLikeActive_CommentedLinesIgnored_ActiveLinesParsed()
{
var mainPath = TestDataHelper.GetPath("real-world/edge/dnsmasq-real-official-like-active.conf");
Assert.True(File.Exists(mainPath));
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.NotNull(portVal);
Assert.True(int.TryParse(portVal, out var port) && port == 5353);
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
Assert.Single(servers);
Assert.Equal("1.1.1.1", servers[0]);
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
}
}
@@ -0,0 +1,73 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Ensures every option in the coverage list is parsed consistently: one config line per option,
/// dispatch by parser behavior (Flag / LastWins / Multi), assert we get a value back.
/// Add new options to <see cref="OptionCoverageData.GetParserCoverageEntries"/> when adding to the app.
/// </summary>
public class DnsmasqConfIncludeParserOptionCoverageTests
{
public static IEnumerable<object[]> GetCoverageEntries()
{
foreach (var (optionKey, configLine) in OptionCoverageData.GetParserCoverageEntries())
yield return new object[] { optionKey, configLine };
}
[Theory]
[MemberData(nameof(GetCoverageEntries))]
public void EveryOption_ParseSingleLine_ReturnsExpectedResult(string optionKey, string configLine)
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-opt-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, configLine + "\n");
var paths = new[] { conf };
if (optionKey == DnsmasqConfKeys.AddnHosts)
{
var addn = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
Assert.True(addn.Count >= 1, $"addn-hosts should parse from: {configLine}");
return;
}
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionKey);
switch (behavior)
{
case EffectiveConfigParserBehavior.Flag:
{
var flag = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, optionKey);
Assert.True(flag, $"Flag option {optionKey} should be true when set.");
break;
}
case EffectiveConfigParserBehavior.LastWins:
{
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, optionKey);
Assert.NotNull(value);
Assert.True(value!.Length > 0, $"LastWins option {optionKey} should return non-empty value.");
break;
}
case EffectiveConfigParserBehavior.Multi:
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, optionKey);
Assert.True(list.Count >= 1, $"Multi option {optionKey} should return at least one value.");
break;
}
default:
Assert.Fail($"Unknown behavior for {optionKey}");
break;
}
}
finally
{
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,148 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Corpus-driven parser tests using testdata/real-world. Asserts counts, booleans, and effective state only.
/// </summary>
public class DnsmasqConfIncludeParserRealWorldCorpusTests
{
public static IEnumerable<object[]> GetCases()
{
foreach (var c in RealWorldCasesHelper.LoadCases())
yield return new object[] { c };
}
[Theory]
[MemberData(nameof(GetCases))]
public void GetIncludedPaths_RealWorldCorpus_ReturnsAtLeastMain(CorpusCase c)
{
var mainPath = RealWorldCasesHelper.Resolve(c);
Assert.True(File.Exists(mainPath), $"Corpus file missing: {c.File}");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
Assert.NotNull(paths);
Assert.NotEmpty(paths);
Assert.Contains(Path.GetFullPath(mainPath), paths);
}
[Theory]
[MemberData(nameof(GetCases))]
public void ParseEffectiveSignals_FromRealWorldCorpus(CorpusCase c)
{
var e = c.Expected;
var hasAny = e.ServerCountMin is not null || e.NoHosts is not null || e.Do0x20State is not null ||
e.Port is not null || e.ExpandHosts is not null || e.LocalCountMin is not null ||
e.AddressCountMin is not null || e.CacheSize is not null || e.AddnHostsCountMin is not null ||
e.DomainCountMin is not null || e.NoResolv is not null || e.BogusPriv is not null ||
e.CnameCountMin is not null || e.TxtRecordCountMin is not null || e.SrvCountMin is not null ||
e.PtrRecordCountMin is not null || e.MxHostCountMin is not null || e.AuthZoneCountMin is not null ||
e.SynthDomainCountMin is not null || e.AuthServerCountMin is not null;
if (!hasAny)
return;
var mainPath = RealWorldCasesHelper.Resolve(c);
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
if (e.ServerCountMin is int minServers)
{
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
Assert.True(servers.Count >= minServers, $"Expected at least {minServers} server(s) for {c.File}");
}
if (e.LocalCountMin is int minLocal)
{
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
Assert.True(locals.Count >= minLocal, $"Expected at least {minLocal} local(s) for {c.File}");
}
if (e.AddressCountMin is int minAddr)
{
var addrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
Assert.True(addrs.Count >= minAddr, $"Expected at least {minAddr} address(es) for {c.File}");
}
if (e.CacheSize is int cacheSize)
{
var (val, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
Assert.NotNull(val);
Assert.True(int.TryParse(val, out var n) && n == cacheSize);
}
if (e.AddnHostsCountMin is int minAddn)
{
var addn = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
Assert.True(addn.Count >= minAddn);
}
if (e.DomainCountMin is int minDomain)
{
var domains = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Domain);
Assert.True(domains.Count >= minDomain);
}
if (e.NoHosts is bool noHostsExpected)
{
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
Assert.Equal(noHostsExpected, noHosts);
}
if (e.NoResolv is true)
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.NoResolv));
}
if (e.BogusPriv is true)
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.BogusPriv));
}
if (e.Do0x20State == "Disabled")
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.No0x20Encode));
}
if (e.Port is int portExpected)
{
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.NotNull(portVal);
Assert.True(int.TryParse(portVal, out var port) && port == portExpected);
}
if (e.ExpandHosts is true)
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
}
if (e.CnameCountMin is int minCname)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Cname);
Assert.True(list.Count >= minCname);
}
if (e.TxtRecordCountMin is int minTxt)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.TxtRecord);
Assert.True(list.Count >= minTxt);
}
if (e.SrvCountMin is int minSrv)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Srv);
Assert.True(list.Count >= minSrv);
}
if (e.PtrRecordCountMin is int minPtr)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.PtrRecord);
Assert.True(list.Count >= minPtr);
}
if (e.MxHostCountMin is int minMx)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.MxHost);
Assert.True(list.Count >= minMx);
}
if (e.AuthZoneCountMin is int minAz)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.AuthZone);
Assert.True(list.Count >= minAz);
}
if (e.SynthDomainCountMin is int minSd)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.SynthDomain);
Assert.True(list.Count >= minSd);
}
if (e.AuthServerCountMin is int minAs)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.AuthServer);
Assert.True(list.Count >= minAs);
}
}
}
@@ -4,6 +4,7 @@ using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
@@ -134,4 +135,52 @@ public class ConfigSetCacheEffectiveConfigTests
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task RealWorldCorpus_EdgeCommentedActiveMix_Do0x20StateAndSource()
{
var mainPath = TestDataHelper.GetPath("real-world/edge/dnsmasq-real-edge-commented-active-mix.conf");
Assert.True(File.Exists(mainPath));
var dir = Path.GetDirectoryName(mainPath)!;
var managedName = "zz-managed.conf";
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Single(snapshot.Config.ServerValues);
Assert.Equal("1.1.1.1", snapshot.Config.ServerValues[0]);
}
[Fact]
public async Task RealWorldCorpus_GoodHomeBasic_ServerCountAndSource()
{
var mainPath = TestDataHelper.GetPath("real-world/good/dnsmasq-real-home-basic.conf");
Assert.True(File.Exists(mainPath));
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "zz-managed.conf" });
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.ServerValues.Count >= 2);
Assert.Equal(2, snapshot.Sources.ServerValues.Count);
}
[Fact]
public async Task RealWorldCorpus_GoodResolverRich_LocalAddressCacheSizeFlags()
{
var mainPath = TestDataHelper.GetPath("real-world/good/dnsmasq-real-resolver-rich.conf");
Assert.True(File.Exists(mainPath));
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "zz-managed.conf" });
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.ServerValues.Count >= 2);
Assert.True(snapshot.Config.LocalValues.Count >= 2);
Assert.True(snapshot.Config.AddressValues.Count >= 2);
Assert.Equal(1000, snapshot.Config.CacheSize);
Assert.True(snapshot.Config.NoResolv);
Assert.True(snapshot.Config.BogusPriv);
Assert.True(snapshot.Config.ExpandHosts);
}
}
@@ -303,6 +303,140 @@ public class DnsmasqConfigServiceApplyChangesTests
}
}
[Fact]
public async Task ApplyChanges_Do0x20Enabled_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Enabled, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Enabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_UseStaleCache_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "60", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal("60", snapshot.Config.UseStaleCache);
Assert.NotNull(snapshot.Sources.UseStaleCache);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Server_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server, null, new List<string> { "1.1.1.1", "8.8.8.8" }, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(2, snapshot.Config.ServerValues.Count);
Assert.Contains("1.1.1.1", snapshot.Config.ServerValues);
Assert.Contains("8.8.8.8", snapshot.Config.ServerValues);
Assert.Equal(2, snapshot.Sources.ServerValues.Count);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Conntrack_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Conntrack, false, true, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.Conntrack);
Assert.NotNull(snapshot.Sources.Conntrack);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Do0x20_Disabled_WritesNo0x20Encode()
{
@@ -1,32 +0,0 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Documents and guards the Do0x20 display label rule: Default and Disabled both show "Disabled" (product choice).
/// Do not "fix" the display to show "Default" for unset without updating this test and the component comment.
/// </summary>
public class Do0x20EncodeDisplayLabelTests
{
/// <summary>Same logic as Do0x20EncodeDisplay.razor view mode: Enabled → "Enabled", else "Disabled".</summary>
private static string GetDisplayLabel(ExplicitToggleState state) =>
state == ExplicitToggleState.Enabled ? "Enabled" : "Disabled";
[Fact]
public void Do0x20_Default_ShowsDisabled()
{
Assert.Equal("Disabled", GetDisplayLabel(ExplicitToggleState.Default));
}
[Fact]
public void Do0x20_Disabled_ShowsDisabled()
{
Assert.Equal("Disabled", GetDisplayLabel(ExplicitToggleState.Disabled));
}
[Fact]
public void Do0x20_Enabled_ShowsEnabled()
{
Assert.Equal("Enabled", GetDisplayLabel(ExplicitToggleState.Enabled));
}
}
@@ -1,22 +0,0 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Ensures effective-config editable options list includes supported cache flags.
/// </summary>
public class EffectiveConfigEditableOptionsTests
{
/// <summary>
/// strip-mac and strip-subnet are supported in the effective-config UI and should be listed in cache options.
/// </summary>
[Fact]
public void EditableOptions_Include_StripMac_And_StripSubnet()
{
var allOptionNames = EffectiveConfigSections.GetSectionsInOrder()
.SelectMany(t => EffectiveConfigSections.GetOptionsInSection(t.SectionId))
.ToHashSet(StringComparer.Ordinal);
Assert.Contains(DnsmasqConfKeys.StripMac, allOptionNames);
Assert.Contains(DnsmasqConfKeys.StripSubnet, allOptionNames);
}
}
@@ -72,7 +72,6 @@ public class EffectiveConfigFeatureRequirementsTests
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, status);
Assert.True(isDisabled);
Assert.NotNull(reason);
Assert.Contains("DNSSEC", reason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -92,8 +91,7 @@ public class EffectiveConfigFeatureRequirementsTests
var noDnssecCaps = new DnsmasqCompileCapabilities(true, true, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP" });
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, noDnssecCaps));
var reason = EffectiveConfigFeatureRequirements.GetUnsupportedReason(DnsmasqFeature.Dnssec);
Assert.Contains("DNSSEC", reason, StringComparison.OrdinalIgnoreCase);
Assert.NotNull(EffectiveConfigFeatureRequirements.GetUnsupportedReason(DnsmasqFeature.Dnssec));
}
private static DnsmasqServiceStatus CreateStatusWithCapabilities(bool dhcp = true, bool tftp = true, bool dnssec = true, bool dbus = false)
@@ -23,7 +23,6 @@ public class EffectiveConfigFieldBuilderCapabilityTests
Assert.True(descriptor.IsCapabilityDisabled);
Assert.NotNull(descriptor.CapabilityDisabledReason);
Assert.Contains("DNSSEC", descriptor.CapabilityDisabledReason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -0,0 +1,110 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Save path capability guard: unsupported-option changes return UnsupportedCapabilities
/// before validation or reload can run.
/// </summary>
public class EffectiveConfigSaveServiceCapabilityTests
{
[Fact]
public async Task SaveAsync_WhenCapabilitiesMissingDnssec_ReturnsUnsupportedCapabilitiesErrorCode()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cap-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var versionService = new StubVersionService(CreateSupportedVersionInfo(dnssec: false));
var semanticValidationService = new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>()));
var saveService = new EffectiveConfigSaveService(
setService,
configService,
cache,
new UnexpectedValidationService(),
semanticValidationService,
new UnexpectedReloadService(),
versionService,
NullLogger<EffectiveConfigSaveService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null)
};
var result = await saveService.SaveAsync(changes);
Assert.Equal(EffectiveConfigSaveResult.ErrorCodes.UnsupportedCapabilities, result.ErrorCode);
Assert.False(File.Exists(Path.Combine(dir, managedName)));
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
private sealed class StubVersionService : IDnsmasqVersionService
{
private readonly DnsmasqVersionInfo _info;
public StubVersionService(DnsmasqVersionInfo info) => _info = info;
public Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default) =>
Task.FromResult(_info);
}
private static DnsmasqVersionInfo CreateSupportedVersionInfo(bool dnssec)
{
var capabilities = new DnsmasqCompileCapabilities(
Dhcp: true,
Tftp: true,
Dnssec: dnssec,
Dbus: false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP" });
return new DnsmasqVersionInfo(
new Version(2, 91),
new Version(2, 91),
ProbeSucceeded: true,
IsSupported: true,
"dnsmasq --version",
null,
capabilities);
}
private sealed class UnexpectedValidationService : IConfigValidationService
{
public Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default) =>
throw new InvalidOperationException("ValidateAsync should not be called when capabilities are unsupported.");
}
private sealed class UnexpectedReloadService : IReloadService
{
public Task<ReloadResult> ReloadAsync(CancellationToken ct = default) =>
throw new InvalidOperationException("ReloadAsync should not be called when capabilities are unsupported.");
}
}
@@ -13,13 +13,15 @@ namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
public class EffectiveConfigSemanticsWiringTests
{
[Fact]
public void ParserBehaviorMap_UsesSpecialSemantics_ForAllSpecialOptions()
public void Registry_WiresSemanticValidator_ForDnsRr()
{
foreach (var option in EffectiveConfigSpecialOptionSemantics.GetAllOptionNames())
{
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(option)!;
Assert.Equal(semantics.ParserBehavior, EffectiveConfigParserBehaviorMap.GetBehavior(option));
}
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DnsRrSemanticHandler()]));
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DnsRr);
Assert.NotNull(factory);
var descriptor = factory!(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DnsRr, status: null, getItems: _ => null);
Assert.NotNull(descriptor.Validator);
Assert.Null(descriptor.Validator!.ValidateItem("example.com,16,01:02", Array.Empty<string>()));
Assert.NotNull(descriptor.Validator.ValidateItem("example.com,not-a-number", Array.Empty<string>()));
}
[Fact]
@@ -0,0 +1,50 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>Corpus-driven semantic validation: bad samples produce errors for expected option keys only.</summary>
public class OptionSemanticValidatorRealWorldCorpusTests
{
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
new DomainSemanticHandler(),
new DnsRrSemanticHandler(),
new SynthDomainSemanticHandler(),
new AuthServerSemanticHandler(),
new SrvSemanticHandler(),
]);
public static IEnumerable<object[]> GetCasesWithInvalidOptions()
{
foreach (var c in RealWorldCasesHelper.LoadCases())
{
if (c.SemanticInvalidOptions.Count > 0)
yield return new object[] { c };
}
}
[Theory]
[MemberData(nameof(GetCasesWithInvalidOptions))]
public void SemanticInvalidOptions_FromCorpus_AreRejected(CorpusCase c)
{
var mainPath = RealWorldCasesHelper.Resolve(c);
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
foreach (var option in c.SemanticInvalidOptions)
{
var values = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, option);
Assert.NotEmpty(values);
var anyInvalid = values.Any(v =>
_validator.ValidateMultiItem(option, v, semantics) is not null);
Assert.True(anyInvalid, $"Expected at least one invalid value for option '{option}' in {c.File}");
}
}
}
@@ -0,0 +1,119 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Ensures every specialized option (one with a semantic handler) has at least one valid and one invalid
/// test case; asserts only Null/NotNull, not error message text.
/// </summary>
public class OptionSemanticValidatorSpecializedTests
{
private static readonly OptionValidationSemantics ComplexAllowEmpty = new(OptionValidationKind.Complex, allowEmpty: true);
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
new LeasequerySemanticHandler(),
new ServerSemanticHandler(),
new LocalSemanticHandler(),
new RevServerSemanticHandler(),
new AddressSemanticHandler(),
new TrustAnchorSemanticHandler(),
new AliasSemanticHandler(),
new IpsetSemanticHandler(),
new NftsetSemanticHandler(),
new IgnoreAddressSemanticHandler(),
new ConnmarkAllowlistSemanticHandler(),
new DhcpRangeSemanticHandler(),
new DhcpHostSemanticHandler(),
new DhcpOptionSemanticHandler(),
new DhcpMatchSemanticHandler(),
new DhcpMacSemanticHandler(),
new DhcpRelaySemanticHandler(),
new DhcpProxySemanticHandler(),
new RaParamSemanticHandler(),
new DhcpNameMatchSemanticHandler(),
new DhcpIgnoreSemanticHandler(),
new DhcpVendorclassSemanticHandler(),
new DhcpUserclassSemanticHandler(),
new TagIfSemanticHandler(),
new BridgeInterfaceSemanticHandler(),
new SharedNetworkSemanticHandler(),
new DhcpOptionPxeSemanticHandler(),
new RebindDomainOkSemanticHandler(),
new BogusNxdomainSemanticHandler(),
new DhcpIgnoreNamesSemanticHandler(),
new DhcpBootSemanticHandler(),
new SlaacSemanticHandler(),
new PxeServiceSemanticHandler(),
new DhcpCircuitidSemanticHandler(),
new DhcpRemoteidSemanticHandler(),
new DhcpSubscrIdSemanticHandler(),
new FilterRrSemanticHandler(),
new CacheRrSemanticHandler(),
new InterfaceNameSemanticHandler(),
new AuthServerSemanticHandler(),
new CnameSemanticHandler(),
new MxHostSemanticHandler(),
new PtrRecordSemanticHandler(),
new InterfaceNameRecordSemanticHandler(),
new CaaRecordSemanticHandler(),
new SrvSemanticHandler(),
new NaptrRecordSemanticHandler(),
new DnsRrSemanticHandler(),
new DynamicHostSemanticHandler(),
new AuthSoaSemanticHandler(),
new AuthSecServersSemanticHandler(),
new AuthPeerSemanticHandler(),
new HostRecordSemanticHandler(),
new TxtRecordSemanticHandler(),
new DomainSemanticHandler(),
new SynthDomainSemanticHandler(),
new AuthZoneSemanticHandler(),
]);
/// <summary>Option key, one valid value (validator returns null), one invalid value (validator returns non-null).</summary>
public static IEnumerable<object[]> GetSpecializedValidInvalidPairs()
{
yield return new object[] { DnsmasqConfKeys.Leasequery, "10.0.0.0/24", "not-an-ip" };
yield return new object[] { DnsmasqConfKeys.Server, "1.1.1.1", "" };
yield return new object[] { DnsmasqConfKeys.Local, "/lan/", "" };
yield return new object[] { DnsmasqConfKeys.Address, "/example.com/127.0.0.1", "/bad..name/1.2.3.4" };
yield return new object[] { DnsmasqConfKeys.Domain, "home.lan,192.168.1.0/24", "example.com,192.168.1.0/24,local,extra" };
yield return new object[] { DnsmasqConfKeys.AuthServer, "zone.example.com,eth0", ",eth0" };
yield return new object[] { DnsmasqConfKeys.SynthDomain, "dynamic.example.com,192.168.2.1,192.168.2.100", "example.com," };
yield return new object[] { DnsmasqConfKeys.DnsRr, "example.com,16,01:02", "example.com,not-a-number" };
yield return new object[] { DnsmasqConfKeys.Srv, "_http._tcp.example.com,host.example.com,80", "_sip._tcp,target,99999" };
yield return new object[] { DnsmasqConfKeys.NaptrRecord, "example.com,0,0,a,s,r", "example.com,not-num,0,a,s,r" };
yield return new object[] { DnsmasqConfKeys.TxtRecord, "example.com,text", "" };
yield return new object[] { DnsmasqConfKeys.Cname, "www.example.com,example.com", "bad..name,example.com" };
yield return new object[] { DnsmasqConfKeys.MxHost, "example.com,mail.example.com,10", "bad..name,mail.example.com,10" };
yield return new object[] { DnsmasqConfKeys.PtrRecord, "1.168.192.in-addr.arpa,router.lan", "bad..arpa,host" };
yield return new object[] { DnsmasqConfKeys.RebindDomainOk, "example.com", "bad..name" };
yield return new object[] { DnsmasqConfKeys.BogusNxdomain, "192.168.1.1", "not-an-ip" };
yield return new object[] { DnsmasqConfKeys.ConnmarkAllowlist, "0xff,example.com", "not-a-mark,example.com" };
yield return new object[] { DnsmasqConfKeys.DhcpRange, "192.168.1.50,192.168.1.150,12h", "not-an-ip,192.168.1.150" };
yield return new object[] { DnsmasqConfKeys.DhcpHost, "aa:bb:cc:dd:ee:ff,192.168.1.10", "ignore" };
yield return new object[] { DnsmasqConfKeys.DhcpOption, "3,192.168.1.1", "option:," };
yield return new object[] { DnsmasqConfKeys.DhcpMatch, "set:efi,option:client-arch,6", "option:client-arch,6" };
yield return new object[] { DnsmasqConfKeys.DhcpMac, "set:vendor,aa:bb:cc:dd:ee:ff", "aa:bb:cc:dd:ee:ff" };
yield return new object[] { DnsmasqConfKeys.DhcpNameMatch, "set:tag,hostname*", "hostname*" };
yield return new object[] { DnsmasqConfKeys.DhcpIgnoreNames, "tag:guest", "guest" };
yield return new object[] { DnsmasqConfKeys.AuthZone, "example.com", "bad..name" };
yield return new object[] { DnsmasqConfKeys.FilterRr, "A,AAAA", "" };
yield return new object[] { DnsmasqConfKeys.CacheRr, "A,TXT", "" };
}
[Theory]
[MemberData(nameof(GetSpecializedValidInvalidPairs))]
public void SpecializedOption_ValidAccepted_InvalidRejected(string optionKey, string validValue, string invalidValue)
{
var validErr = _validator.ValidateMultiItem(optionKey, validValue, ComplexAllowEmpty);
Assert.Null(validErr);
var invalidErr = _validator.ValidateMultiItem(optionKey, invalidValue, ComplexAllowEmpty);
Assert.NotNull(invalidErr);
}
}
@@ -937,4 +937,72 @@ public class OptionSemanticValidatorTests
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "bad..name", semantics));
}
[Fact]
public void ValidateMultiItem_AuthServer_InvalidGlueDomain_IsRejected()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, ",eth0", semantics);
Assert.NotNull(err);
}
[Fact]
public void ValidateMultiItem_SynthDomain_InvalidAddressRange_IsRejected()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "example.com,", semantics);
Assert.NotNull(err);
}
[Fact]
public void ValidateMultiItem_Domain_ExtraFields_AreRejected()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "example.com,192.168.1.0/24,local,extra", semantics);
Assert.NotNull(err);
}
[Theory]
[InlineData("example.com,not-a-number")]
[InlineData(",16,01:02")]
[InlineData("bad..name,16,01:02")]
public void ValidateMultiItem_DnsRr_Malformed_IsRejected(string value)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, value, semantics);
Assert.NotNull(err);
}
[Theory]
[InlineData("_sip._tcp,host,not-a-port")]
[InlineData("")]
[InlineData("_sip._tcp,target,99999")] // port out of range
public void ValidateMultiItem_SrvHost_Malformed_IsRejected(string value)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Srv, value, semantics);
Assert.NotNull(err);
}
[Theory]
[InlineData("example.com,not-num,0,a,s,r")]
[InlineData("bad..name,0,0,a,s,r")]
[InlineData("short,0,0,a,s")] // fewer than 6 fields
public void ValidateMultiItem_NaptrRecord_Malformed_IsRejected(string value)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, value, semantics);
Assert.NotNull(err);
}
[Theory]
[InlineData("")]
[InlineData("bad..name,text")]
[InlineData(",only-text")]
public void ValidateMultiItem_TxtRecord_Malformed_IsRejected(string value)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, value, semantics);
Assert.NotNull(err);
}
}
@@ -26,6 +26,8 @@ public sealed class DomainSemanticHandler : IOptionSemanticHandler
var domain = tokens[0];
if (domain != "#" && !DnsmasqScopedDomainSyntax.IsValidDnsName(domain))
return "domain must be a valid DNS name or #.";
if (tokens.Length > 3)
return "domain has at most 3 fields: domain[,address range[,local]] or domain,interface.";
if (tokens.Length >= 2 && tokens[1].Length == 0)
return "domain cannot have empty fields.";
if (tokens.Length >= 2 && !IsValidSecondToken(tokens[1]))
@@ -0,0 +1 @@
auth-server=,eth0
@@ -0,0 +1 @@
dns-rr=example.com,not-a-number,01:02
@@ -0,0 +1 @@
domain=example.com,192.168.1.0/24,local,extra
@@ -0,0 +1 @@
srv-host=_sip._tcp.example.com,host,99999
@@ -0,0 +1 @@
synth-domain=example.com,
+104
View File
@@ -0,0 +1,104 @@
[
{
"file": "real-world/good/dnsmasq-real-home-basic.conf",
"expected": {
"serverCountMin": 2,
"noHosts": false,
"cacheSize": 500,
"domainCountMin": 1,
"expandHosts": true
},
"semanticInvalidOptions": []
},
{
"file": "real-world/good/dnsmasq-real-dhcp-pxe.conf",
"expected": {},
"semanticInvalidOptions": []
},
{
"file": "real-world/good/dnsmasq-real-resolver-rich.conf",
"expected": {
"serverCountMin": 2,
"localCountMin": 2,
"addressCountMin": 2,
"cacheSize": 1000,
"domainCountMin": 1,
"noResolv": true,
"bogusPriv": true,
"expandHosts": true
},
"semanticInvalidOptions": []
},
{
"file": "real-world/good/dnsmasq-real-dns-records.conf",
"expected": {
"cnameCountMin": 1,
"txtRecordCountMin": 1,
"srvCountMin": 1,
"ptrRecordCountMin": 1,
"mxHostCountMin": 1
},
"semanticInvalidOptions": []
},
{
"file": "real-world/good/dnsmasq-real-auth-synth.conf",
"expected": {
"authZoneCountMin": 1,
"synthDomainCountMin": 1,
"authServerCountMin": 1
},
"semanticInvalidOptions": []
},
{
"file": "real-world/bad/dnsmasq-real-bad-domain-extra-fields.conf",
"expected": {},
"semanticInvalidOptions": ["domain"]
},
{
"file": "real-world/bad/dnsmasq-real-bad-dns-rr-type.conf",
"expected": {},
"semanticInvalidOptions": ["dns-rr"]
},
{
"file": "real-world/bad/dnsmasq-real-bad-synth-domain-missing-range.conf",
"expected": {},
"semanticInvalidOptions": ["synth-domain"]
},
{
"file": "real-world/bad/dnsmasq-real-bad-auth-server.conf",
"expected": {},
"semanticInvalidOptions": ["auth-server"]
},
{
"file": "real-world/bad/dnsmasq-real-bad-srv-host.conf",
"expected": {},
"semanticInvalidOptions": ["srv-host"]
},
{
"file": "real-world/edge/dnsmasq-real-edge-commented-active-mix.conf",
"expected": {
"serverCountMin": 1,
"do0x20State": "Disabled"
},
"semanticInvalidOptions": []
},
{
"file": "real-world/edge/dnsmasq-real-edge-flags-mix.conf",
"expected": {
"noResolv": true,
"bogusPriv": true,
"expandHosts": true,
"cacheSize": 200
},
"semanticInvalidOptions": []
},
{
"file": "real-world/edge/dnsmasq-real-official-like-active.conf",
"expected": {
"serverCountMin": 1,
"port": 5353,
"expandHosts": true
},
"semanticInvalidOptions": []
}
]
@@ -0,0 +1,4 @@
# server=9.9.9.9
server=1.1.1.1
# do-0x20-encode
no-0x20-encode
@@ -0,0 +1,6 @@
# no-resolv
no-resolv
expand-hosts
# bogus-priv
bogus-priv
cache-size=200
@@ -0,0 +1,6 @@
# port=53
port=5353
# server=8.8.8.8
server=1.1.1.1
# expand-hosts
expand-hosts
+3
View File
@@ -0,0 +1,3 @@
auth-zone=example.com
synth-domain=dynamic.example.com,192.168.2.1,192.168.2.100
auth-server=zone.example.com,eth0
+5
View File
@@ -0,0 +1,5 @@
port=53
dhcp-range=192.168.1.50,192.168.1.150,12h
enable-tftp
tftp-root=/var/lib/tftpboot
dhcp-boot=pxelinux.0
@@ -0,0 +1,5 @@
cname=www.example.com,example.com
txt-record=example.com,"v=spf1 -all"
srv-host=_http._tcp.example.com,host.example.com,80,10,10
ptr-record=1.168.192.in-addr.arpa,router.home.lan
mx-host=example.com,mail.example.com,5
+6
View File
@@ -0,0 +1,6 @@
port=53
cache-size=500
server=1.1.1.1
server=8.8.8.8
expand-hosts
domain=home.arpa,192.168.1.0/24
@@ -0,0 +1,13 @@
port=53
cache-size=1000
no-resolv
domain-needed
bogus-priv
server=1.1.1.1
server=8.8.8.8
local=/lan/
local=/localdomain/
address=/home.lan/192.168.1.1
address=/router.local/192.168.1.1
domain=home.lan,192.168.1.0/24
expand-hosts