From 3ca935e55104505b9b78d4428a1983ae323d1f52 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Wed, 11 Mar 2026 11:44:01 +1000 Subject: [PATCH] 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. --- .../Helpers/OptionCoverageData.cs | 100 ++++++++++++ .../Helpers/RealWorldCasesHelper.cs | 51 ++++++ .../Helpers/RealWorldCorpusIntegrityTests.cs | 50 ++++++ .../Models/ConfigValueSourceTests.cs | 59 ------- ...sqConfIncludeParserOfficialExampleTests.cs | 45 +++--- ...asqConfIncludeParserOptionCoverageTests.cs | 73 +++++++++ ...sqConfIncludeParserRealWorldCorpusTests.cs | 148 ++++++++++++++++++ .../ConfigSetCacheEffectiveConfigTests.cs | 49 ++++++ .../DnsmasqConfigServiceApplyChangesTests.cs | 134 ++++++++++++++++ .../Do0x20EncodeDisplayLabelTests.cs | 32 ---- .../EffectiveConfigEditableOptionsTests.cs | 22 --- ...EffectiveConfigFeatureRequirementsTests.cs | 4 +- ...ectiveConfigFieldBuilderCapabilityTests.cs | 1 - ...fectiveConfigSaveServiceCapabilityTests.cs | 110 +++++++++++++ .../EffectiveConfigSemanticsWiringTests.cs | 14 +- ...onSemanticValidatorRealWorldCorpusTests.cs | 50 ++++++ ...OptionSemanticValidatorSpecializedTests.cs | 119 ++++++++++++++ .../OptionSemanticValidatorTests.cs | 68 ++++++++ .../Handlers/DomainSemanticHandler.cs | 2 + .../bad/dnsmasq-real-bad-auth-server.conf | 1 + .../bad/dnsmasq-real-bad-dns-rr-type.conf | 1 + .../dnsmasq-real-bad-domain-extra-fields.conf | 1 + .../bad/dnsmasq-real-bad-srv-host.conf | 1 + ...q-real-bad-synth-domain-missing-range.conf | 1 + testdata/real-world/dnsmasq-real-cases.json | 104 ++++++++++++ ...nsmasq-real-edge-commented-active-mix.conf | 4 + .../edge/dnsmasq-real-edge-flags-mix.conf | 6 + .../dnsmasq-real-official-like-active.conf | 6 + .../good/dnsmasq-real-auth-synth.conf | 3 + .../good/dnsmasq-real-dhcp-pxe.conf | 5 + .../good/dnsmasq-real-dns-records.conf | 5 + .../good/dnsmasq-real-home-basic.conf | 6 + .../good/dnsmasq-real-resolver-rich.conf | 13 ++ 33 files changed, 1139 insertions(+), 149 deletions(-) create mode 100644 src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs create mode 100644 src/DnsmasqWebUI.Tests/Helpers/RealWorldCasesHelper.cs create mode 100644 src/DnsmasqWebUI.Tests/Helpers/RealWorldCorpusIntegrityTests.cs delete mode 100644 src/DnsmasqWebUI.Tests/Models/ConfigValueSourceTests.cs create mode 100644 src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOptionCoverageTests.cs create mode 100644 src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserRealWorldCorpusTests.cs delete mode 100644 src/DnsmasqWebUI.Tests/Services/EffectiveConfig/Do0x20EncodeDisplayLabelTests.cs delete mode 100644 src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditableOptionsTests.cs create mode 100644 src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceCapabilityTests.cs create mode 100644 src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorRealWorldCorpusTests.cs create mode 100644 src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorSpecializedTests.cs create mode 100644 testdata/real-world/bad/dnsmasq-real-bad-auth-server.conf create mode 100644 testdata/real-world/bad/dnsmasq-real-bad-dns-rr-type.conf create mode 100644 testdata/real-world/bad/dnsmasq-real-bad-domain-extra-fields.conf create mode 100644 testdata/real-world/bad/dnsmasq-real-bad-srv-host.conf create mode 100644 testdata/real-world/bad/dnsmasq-real-bad-synth-domain-missing-range.conf create mode 100644 testdata/real-world/dnsmasq-real-cases.json create mode 100644 testdata/real-world/edge/dnsmasq-real-edge-commented-active-mix.conf create mode 100644 testdata/real-world/edge/dnsmasq-real-edge-flags-mix.conf create mode 100644 testdata/real-world/edge/dnsmasq-real-official-like-active.conf create mode 100644 testdata/real-world/good/dnsmasq-real-auth-synth.conf create mode 100644 testdata/real-world/good/dnsmasq-real-dhcp-pxe.conf create mode 100644 testdata/real-world/good/dnsmasq-real-dns-records.conf create mode 100644 testdata/real-world/good/dnsmasq-real-home-basic.conf create mode 100644 testdata/real-world/good/dnsmasq-real-resolver-rich.conf diff --git a/src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs b/src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs new file mode 100644 index 0000000..912fe56 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Helpers/OptionCoverageData.cs @@ -0,0 +1,100 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; + +namespace DnsmasqWebUI.Tests.Helpers; + +/// +/// 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. +/// +public static class OptionCoverageData +{ + /// Option key and a minimal valid config line (without newline). + 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"), + }; + } +} diff --git a/src/DnsmasqWebUI.Tests/Helpers/RealWorldCasesHelper.cs b/src/DnsmasqWebUI.Tests/Helpers/RealWorldCasesHelper.cs new file mode 100644 index 0000000..55860e6 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Helpers/RealWorldCasesHelper.cs @@ -0,0 +1,51 @@ +using System.Text.Json; + +namespace DnsmasqWebUI.Tests.Helpers; + +/// Expected parser/effective state for a real-world corpus case. Counts and booleans only. +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; } +} + +/// Single real-world corpus case from dnsmasq-real-cases.json. +public sealed class CorpusCase +{ + public string File { get; set; } = ""; + public CorpusExpected Expected { get; set; } = new(); + public List SemanticInvalidOptions { get; set; } = []; +} + +/// Loads real-world corpus index and resolves paths via TestDataHelper. +public static class RealWorldCasesHelper +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public static IReadOnlyList LoadCases() + { + var path = TestDataHelper.GetPath("real-world/dnsmasq-real-cases.json"); + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + + public static string Resolve(CorpusCase c) => TestDataHelper.GetPath(c.File); +} diff --git a/src/DnsmasqWebUI.Tests/Helpers/RealWorldCorpusIntegrityTests.cs b/src/DnsmasqWebUI.Tests/Helpers/RealWorldCorpusIntegrityTests.cs new file mode 100644 index 0000000..91d18a3 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Helpers/RealWorldCorpusIntegrityTests.cs @@ -0,0 +1,50 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig; + +namespace DnsmasqWebUI.Tests.Helpers; + +/// Ensures corpus index and files stay in sync; fails fast when testdata drifts. +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); + } + } + } +} diff --git a/src/DnsmasqWebUI.Tests/Models/ConfigValueSourceTests.cs b/src/DnsmasqWebUI.Tests/Models/ConfigValueSourceTests.cs deleted file mode 100644 index 78316c0..0000000 --- a/src/DnsmasqWebUI.Tests/Models/ConfigValueSourceTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; - -namespace DnsmasqWebUI.Tests.Models; - -/// -/// Tests for and . -/// -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); - } -} diff --git a/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOfficialExampleTests.cs b/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOfficialExampleTests.cs index 36feaf3..5dda710 100644 --- a/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOfficialExampleTests.cs +++ b/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOfficialExampleTests.cs @@ -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); } + + /// Official-like sample with commented and active lines: commented ignored, active parsed, state reflects active only. + [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)); + } } diff --git a/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOptionCoverageTests.cs b/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOptionCoverageTests.cs new file mode 100644 index 0000000..237b14f --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserOptionCoverageTests.cs @@ -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; + +/// +/// 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 when adding to the app. +/// +public class DnsmasqConfIncludeParserOptionCoverageTests +{ + public static IEnumerable 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); + } + } +} diff --git a/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserRealWorldCorpusTests.cs b/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserRealWorldCorpusTests.cs new file mode 100644 index 0000000..949300a --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Serialization/Parsers/DnsmasqConfig/DnsmasqConfIncludeParserRealWorldCorpusTests.cs @@ -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; + +/// +/// Corpus-driven parser tests using testdata/real-world. Asserts counts, booleans, and effective state only. +/// +public class DnsmasqConfIncludeParserRealWorldCorpusTests +{ + public static IEnumerable 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); + } + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/ConfigSetCacheEffectiveConfigTests.cs b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/ConfigSetCacheEffectiveConfigTests.cs index 90d2159..ddb24da 100644 --- a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/ConfigSetCacheEffectiveConfigTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/ConfigSetCacheEffectiveConfigTests.cs @@ -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.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.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.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); + } } diff --git a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/DnsmasqConfigServiceApplyChangesTests.cs b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/DnsmasqConfigServiceApplyChangesTests.cs index f4431f6..c8ad890 100644 --- a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/DnsmasqConfigServiceApplyChangesTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/DnsmasqConfigServiceApplyChangesTests.cs @@ -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.Instance); + var setService = new DnsmasqConfigSetService(cache); + var configService = new DnsmasqConfigService(setService, cache, NullLogger.Instance); + var changes = new List + { + 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.Instance); + var setService = new DnsmasqConfigSetService(cache); + var configService = new DnsmasqConfigService(setService, cache, NullLogger.Instance); + var changes = new List + { + 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.Instance); + var setService = new DnsmasqConfigSetService(cache); + var configService = new DnsmasqConfigService(setService, cache, NullLogger.Instance); + var changes = new List + { + new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server, null, new List { "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.Instance); + var setService = new DnsmasqConfigSetService(cache); + var configService = new DnsmasqConfigService(setService, cache, NullLogger.Instance); + var changes = new List + { + 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() { diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/Do0x20EncodeDisplayLabelTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/Do0x20EncodeDisplayLabelTests.cs deleted file mode 100644 index df015e6..0000000 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/Do0x20EncodeDisplayLabelTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; - -namespace DnsmasqWebUI.Tests.Services.EffectiveConfig; - -/// -/// 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. -/// -public class Do0x20EncodeDisplayLabelTests -{ - /// Same logic as Do0x20EncodeDisplay.razor view mode: Enabled → "Enabled", else "Disabled". - 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)); - } -} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditableOptionsTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditableOptionsTests.cs deleted file mode 100644 index b87dadd..0000000 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditableOptionsTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; - -namespace DnsmasqWebUI.Tests.Services.EffectiveConfig; - -/// -/// Ensures effective-config editable options list includes supported cache flags. -/// -public class EffectiveConfigEditableOptionsTests -{ - /// - /// strip-mac and strip-subnet are supported in the effective-config UI and should be listed in cache options. - /// - [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); - } -} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigFeatureRequirementsTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigFeatureRequirementsTests.cs index 20534f9..58afaa1 100644 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigFeatureRequirementsTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigFeatureRequirementsTests.cs @@ -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(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) diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigFieldBuilderCapabilityTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigFieldBuilderCapabilityTests.cs index f626675..4ff5078 100644 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigFieldBuilderCapabilityTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigFieldBuilderCapabilityTests.cs @@ -23,7 +23,6 @@ public class EffectiveConfigFieldBuilderCapabilityTests Assert.True(descriptor.IsCapabilityDisabled); Assert.NotNull(descriptor.CapabilityDisabledReason); - Assert.Contains("DNSSEC", descriptor.CapabilityDisabledReason, StringComparison.OrdinalIgnoreCase); } [Fact] diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceCapabilityTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceCapabilityTests.cs new file mode 100644 index 0000000..d80db4b --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceCapabilityTests.cs @@ -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; + +/// +/// Save path capability guard: unsupported-option changes return UnsupportedCapabilities +/// before validation or reload can run. +/// +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.Instance); + var setService = new DnsmasqConfigSetService(cache); + var configService = new DnsmasqConfigService(setService, cache, NullLogger.Instance); + + var versionService = new StubVersionService(CreateSupportedVersionInfo(dnssec: false)); + var semanticValidationService = new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty())); + + var saveService = new EffectiveConfigSaveService( + setService, + configService, + cache, + new UnexpectedValidationService(), + semanticValidationService, + new UnexpectedReloadService(), + versionService, + NullLogger.Instance); + + var changes = new List + { + 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 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(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 ValidateAsync(CancellationToken ct = default) => + throw new InvalidOperationException("ValidateAsync should not be called when capabilities are unsupported."); + } + + private sealed class UnexpectedReloadService : IReloadService + { + public Task ReloadAsync(CancellationToken ct = default) => + throw new InvalidOperationException("ReloadAsync should not be called when capabilities are unsupported."); + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSemanticsWiringTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSemanticsWiringTests.cs index be63a98..f24b2f7 100644 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSemanticsWiringTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSemanticsWiringTests.cs @@ -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())); + Assert.NotNull(descriptor.Validator.ValidateItem("example.com,not-a-number", Array.Empty())); } [Fact] diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorRealWorldCorpusTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorRealWorldCorpusTests.cs new file mode 100644 index 0000000..9e23cac --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorRealWorldCorpusTests.cs @@ -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; + +/// Corpus-driven semantic validation: bad samples produce errors for expected option keys only. +public class OptionSemanticValidatorRealWorldCorpusTests +{ + private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([ + new DomainSemanticHandler(), + new DnsRrSemanticHandler(), + new SynthDomainSemanticHandler(), + new AuthServerSemanticHandler(), + new SrvSemanticHandler(), + ]); + + public static IEnumerable 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}"); + } + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorSpecializedTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorSpecializedTests.cs new file mode 100644 index 0000000..442985e --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorSpecializedTests.cs @@ -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; + +/// +/// 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. +/// +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(), + ]); + + /// Option key, one valid value (validator returns null), one invalid value (validator returns non-null). + public static IEnumerable 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); + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorTests.cs index 6023dc8..0974f69 100644 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/OptionSemanticValidatorTests.cs @@ -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); + } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Handlers/DomainSemanticHandler.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Handlers/DomainSemanticHandler.cs index f85a85f..5fc8414 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Handlers/DomainSemanticHandler.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/Handlers/DomainSemanticHandler.cs @@ -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])) diff --git a/testdata/real-world/bad/dnsmasq-real-bad-auth-server.conf b/testdata/real-world/bad/dnsmasq-real-bad-auth-server.conf new file mode 100644 index 0000000..8d9f387 --- /dev/null +++ b/testdata/real-world/bad/dnsmasq-real-bad-auth-server.conf @@ -0,0 +1 @@ +auth-server=,eth0 diff --git a/testdata/real-world/bad/dnsmasq-real-bad-dns-rr-type.conf b/testdata/real-world/bad/dnsmasq-real-bad-dns-rr-type.conf new file mode 100644 index 0000000..921d939 --- /dev/null +++ b/testdata/real-world/bad/dnsmasq-real-bad-dns-rr-type.conf @@ -0,0 +1 @@ +dns-rr=example.com,not-a-number,01:02 diff --git a/testdata/real-world/bad/dnsmasq-real-bad-domain-extra-fields.conf b/testdata/real-world/bad/dnsmasq-real-bad-domain-extra-fields.conf new file mode 100644 index 0000000..d0ca8d0 --- /dev/null +++ b/testdata/real-world/bad/dnsmasq-real-bad-domain-extra-fields.conf @@ -0,0 +1 @@ +domain=example.com,192.168.1.0/24,local,extra diff --git a/testdata/real-world/bad/dnsmasq-real-bad-srv-host.conf b/testdata/real-world/bad/dnsmasq-real-bad-srv-host.conf new file mode 100644 index 0000000..a52eb05 --- /dev/null +++ b/testdata/real-world/bad/dnsmasq-real-bad-srv-host.conf @@ -0,0 +1 @@ +srv-host=_sip._tcp.example.com,host,99999 diff --git a/testdata/real-world/bad/dnsmasq-real-bad-synth-domain-missing-range.conf b/testdata/real-world/bad/dnsmasq-real-bad-synth-domain-missing-range.conf new file mode 100644 index 0000000..97a97a8 --- /dev/null +++ b/testdata/real-world/bad/dnsmasq-real-bad-synth-domain-missing-range.conf @@ -0,0 +1 @@ +synth-domain=example.com, diff --git a/testdata/real-world/dnsmasq-real-cases.json b/testdata/real-world/dnsmasq-real-cases.json new file mode 100644 index 0000000..f85e367 --- /dev/null +++ b/testdata/real-world/dnsmasq-real-cases.json @@ -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": [] + } +] diff --git a/testdata/real-world/edge/dnsmasq-real-edge-commented-active-mix.conf b/testdata/real-world/edge/dnsmasq-real-edge-commented-active-mix.conf new file mode 100644 index 0000000..6e46fbc --- /dev/null +++ b/testdata/real-world/edge/dnsmasq-real-edge-commented-active-mix.conf @@ -0,0 +1,4 @@ +# server=9.9.9.9 +server=1.1.1.1 +# do-0x20-encode +no-0x20-encode diff --git a/testdata/real-world/edge/dnsmasq-real-edge-flags-mix.conf b/testdata/real-world/edge/dnsmasq-real-edge-flags-mix.conf new file mode 100644 index 0000000..43ce6f1 --- /dev/null +++ b/testdata/real-world/edge/dnsmasq-real-edge-flags-mix.conf @@ -0,0 +1,6 @@ +# no-resolv +no-resolv +expand-hosts +# bogus-priv +bogus-priv +cache-size=200 diff --git a/testdata/real-world/edge/dnsmasq-real-official-like-active.conf b/testdata/real-world/edge/dnsmasq-real-official-like-active.conf new file mode 100644 index 0000000..4176d8b --- /dev/null +++ b/testdata/real-world/edge/dnsmasq-real-official-like-active.conf @@ -0,0 +1,6 @@ +# port=53 +port=5353 +# server=8.8.8.8 +server=1.1.1.1 +# expand-hosts +expand-hosts diff --git a/testdata/real-world/good/dnsmasq-real-auth-synth.conf b/testdata/real-world/good/dnsmasq-real-auth-synth.conf new file mode 100644 index 0000000..9f34262 --- /dev/null +++ b/testdata/real-world/good/dnsmasq-real-auth-synth.conf @@ -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 diff --git a/testdata/real-world/good/dnsmasq-real-dhcp-pxe.conf b/testdata/real-world/good/dnsmasq-real-dhcp-pxe.conf new file mode 100644 index 0000000..2ead5c5 --- /dev/null +++ b/testdata/real-world/good/dnsmasq-real-dhcp-pxe.conf @@ -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 diff --git a/testdata/real-world/good/dnsmasq-real-dns-records.conf b/testdata/real-world/good/dnsmasq-real-dns-records.conf new file mode 100644 index 0000000..2637029 --- /dev/null +++ b/testdata/real-world/good/dnsmasq-real-dns-records.conf @@ -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 diff --git a/testdata/real-world/good/dnsmasq-real-home-basic.conf b/testdata/real-world/good/dnsmasq-real-home-basic.conf new file mode 100644 index 0000000..caa5626 --- /dev/null +++ b/testdata/real-world/good/dnsmasq-real-home-basic.conf @@ -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 diff --git a/testdata/real-world/good/dnsmasq-real-resolver-rich.conf b/testdata/real-world/good/dnsmasq-real-resolver-rich.conf new file mode 100644 index 0000000..7ce5df1 --- /dev/null +++ b/testdata/real-world/good/dnsmasq-real-resolver-rich.conf @@ -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