diff --git a/Dockerfile b/Dockerfile index e06eda8..880b85d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,8 @@ RUN dotnet build "DnsmasqWebUI.csproj" -c Release -o /app/build FROM build AS publish RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false +# Runs as root by default. For host dnsmasq + container UI: bind-mount host config dir +# and ensure the mount is writable by the container user, or run this image as root. FROM base AS final WORKDIR /app ENV ASPNETCORE_URLS=http://+:8080 diff --git a/Dockerfile.dnsmasq b/Dockerfile.dnsmasq index 3bd1632..c182965 100644 --- a/Dockerfile.dnsmasq +++ b/Dockerfile.dnsmasq @@ -1,5 +1,7 @@ -# App + dnsmasq in one container (test harness). Stages mirror Dockerfile; final stage adds dnsmasq. -# One build: no need to build or tag the standalone app image first. +# App + dnsmasq in one container (test harness). Both run as root; file permissions work +# because config dirs are in the container. For UI in container + dnsmasq on host, see +# DnsmasqOptions XML doc (permissions and ReloadCommand/StatusCommand scope). +# Stages mirror Dockerfile; final stage adds dnsmasq. FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base WORKDIR /app @@ -25,6 +27,6 @@ COPY --from=publish /app/publish . FROM app AS final RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \ && rm -rf /var/lib/apt/lists/* -COPY entrypoint.sh . +COPY scripts/entrypoint.sh . RUN chmod +x entrypoint.sh ENTRYPOINT ["./entrypoint.sh"] diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 0818242..4d8085a 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -17,9 +17,9 @@ services: - NET_ADMIN environment: DNSMASQ_CONF: /data/dnsmasq-test.conf + Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf + Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf Dnsmasq__HostsPath: /data/hosts - Dnsmasq__ConfigPath: /data/dnsmasq.d/dhcp.conf - Dnsmasq__LeasesPath: /data/leases Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq" Dnsmasq__StatusCommand: "pgrep -x dnsmasq" volumes: diff --git a/entrypoint.sh b/scripts/entrypoint.sh old mode 100644 new mode 100755 similarity index 100% rename from entrypoint.sh rename to scripts/entrypoint.sh diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh index 9152d3a..e5aec8d 100755 --- a/scripts/prepare-test-mount.sh +++ b/scripts/prepare-test-mount.sh @@ -1,6 +1,12 @@ #!/usr/bin/env sh -# Prepare testdata-mount from testdata (or --source) and optionally run the Docker test harness. -# testdata/leases is excluded so dnsmasq creates the real leases file in the container. +# Prepare the testdata mount and optionally start the Docker test harness +# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml. +# +# What this script does: +# 1. Clears the mount directory (unless --no-clear), then syncs source -> mount. +# 2. Excludes 'leases' so dnsmasq creates/owns the real leases file in the container. +# 3. Removes any *dnsmasq-webui*.conf so dnsmasq starts clean (app creates zz-dnsmasq-webui.conf at startup). +# 4. Unless --prepare-only, runs: docker compose -f docker-compose.test.yml up -d [--build] [--force-recreate] set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -12,26 +18,61 @@ MOUNT_DIR="" PREPARE_ONLY=false NO_BUILD=false RECREATE=false +NO_CLEAR=false usage() { echo "Usage: $0 [OPTIONS] [--]" echo "" - echo "Copy test data into the mount directory (default: testdata -> testdata-mount)," - echo "then run 'docker compose -f $COMPOSE_FILE up' unless --prepare-only is set." + echo "Prepare the testdata mount directory and optionally start the Docker test harness" + echo "(app with dnsmasq in one container, plus a DHCP client). The mount is synced from" + echo "testdata/ by default; 'leases' is excluded so dnsmasq creates the real leases file." echo "" - echo "Options:" - echo " -h, --help Show this help" - echo " --source DIR Source directory to copy from (default: testdata)" + echo "Steps:" + echo " 1. Clear mount dir (unless --no-clear), then sync source -> mount." + echo " 2. Remove any leftover *dnsmasq-webui*.conf so dnsmasq starts clean." + echo " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]." + echo "" + echo "Path options:" + echo " --source DIR Source to copy from (default: testdata)" echo " --mount DIR Target mount directory (default: testdata-mount)" - echo " --prepare-only Only copy data; do not run docker compose" - echo " --no-build Run 'docker compose up' without --build (use existing images)" - echo " --recreate Pass --force-recreate to docker compose up" + echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount." + echo "" + echo "Mount behaviour:" + echo " (default) Clear mount dir completely, then sync. Use for a clean run." + echo " --no-clear Do not clear mount dir; only sync over existing contents." + echo " Use to preserve leases or debug files between runs." + echo "" + echo "Compose behaviour:" + echo " --prepare-only Only prepare the mount; do not run docker compose." + echo " Use to inspect or edit the mount before starting containers." + echo " --no-build Do not pass --build to docker compose (use existing images)." + echo " Use for a quick restart when only the mount changed." + echo " --recreate Pass --force-recreate to docker compose (recreate containers)." + echo " Use to ensure fresh container state and mounts." + echo "" + echo "Other:" + echo " -h, --help Show this help and exit." echo "" echo "Examples:" - echo " $0 # Prepare from testdata, then up --build" - echo " $0 --no-build # Prepare, then up without rebuilding" - echo " $0 --prepare-only # Only sync testdata -> testdata-mount" + echo " $0" + echo " Full run: clear mount, sync testdata, build and start containers." + echo "" + echo " $0 --no-build" + echo " Clear and sync, then start containers without rebuilding images." + echo "" + echo " $0 --recreate" + echo " Clear and sync, then up --build --force-recreate (clean containers)." + echo "" + echo " $0 --no-clear --no-build" + echo " Preserve mount contents, sync over it, start without rebuild." + echo "" + echo " $0 --prepare-only" + echo " Only clear and sync testdata -> testdata-mount; no containers." + echo " Then run: docker compose -f $COMPOSE_FILE up -d [--build]" + echo "" echo " $0 --source myfixtures --mount mymount --prepare-only" + echo " Sync myfixtures -> mymount only. Start with:" + echo " TESTDATA_MOUNT=./mymount docker compose -f $COMPOSE_FILE up -d" } while [ $# -gt 0 ]; do @@ -52,6 +93,10 @@ while [ $# -gt 0 ]; do MOUNT_DIR="$1" shift ;; + --no-clear) + NO_CLEAR=true + shift + ;; --prepare-only) PREPARE_ONLY=true shift @@ -88,18 +133,25 @@ fi mkdir -p "$MOUNT_DIR" +if [ "$NO_CLEAR" = false ]; then + echo "Clearing mount directory: $MOUNT_DIR" + find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true +fi + if command -v rsync >/dev/null 2>&1; then rsync -a --exclude=leases "$SOURCE_DIR/" "$MOUNT_DIR/" else - find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true cp -r "$SOURCE_DIR/." "$MOUNT_DIR/" rm -f "$MOUNT_DIR/leases" fi +# Remove any leftover managed config from previous runs so dnsmasq starts clean (app will create zz-dnsmasq-webui.conf on startup). +find "$MOUNT_DIR" -name '*dnsmasq-webui*.conf' -type f -delete 2>/dev/null || true + echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR, leases excluded)." if [ "$PREPARE_ONLY" = true ]; then - echo "Run manually: docker compose -f $COMPOSE_FILE up --build" + echo "To start the harness: TESTDATA_MOUNT=./$MOUNT_DIR docker compose -f $COMPOSE_FILE up -d --build" exit 0 fi diff --git a/src/DnsmasqWebUI.Tests/DhcpHostParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs similarity index 66% rename from src/DnsmasqWebUI.Tests/DhcpHostParserTests.cs rename to src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs index 43a05bc..6e0c7cb 100644 --- a/src/DnsmasqWebUI.Tests/DhcpHostParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs @@ -4,23 +4,23 @@ using DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Tests; /// -/// Tests for DhcpHostParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore] +/// Tests for DnsmasqConfDhcpHostLineParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore] /// Comma-separated; ## = deleted, # = comment; trailing # comment allowed. /// -public class DhcpHostParserTests +public class DnsmasqConfDhcpHostLineParserTests { [Fact] public void ParseLine_NotDhcpHost_ReturnsNull() { - Assert.Null(DhcpHostParser.ParseLine("domain=local", 1)); - Assert.Null(DhcpHostParser.ParseLine("dhcp-range=192.168.1.1,192.168.1.100", 1)); - Assert.Null(DhcpHostParser.ParseLine("# comment", 1)); + Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("domain=local", 1)); + Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-range=192.168.1.1,192.168.1.100", 1)); + Assert.Null(DnsmasqConfDhcpHostLineParser.ParseLine("# comment", 1)); } [Fact] public void ParseLine_BasicMacIpNameLease_Parses() { - var e = DhcpHostParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1); Assert.NotNull(e); Assert.False(e!.IsComment); Assert.False(e.IsDeleted); @@ -35,7 +35,7 @@ public class DhcpHostParserTests [Fact] public void ParseLine_CommentedLine_ParsesWithIsComment() { - var e = DhcpHostParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1); Assert.NotNull(e); Assert.True(e!.IsComment); Assert.False(e.IsDeleted); @@ -46,7 +46,7 @@ public class DhcpHostParserTests [Fact] public void ParseLine_DeletedLine_ParsesWithIsDeleted() { - var e = DhcpHostParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc", 1); Assert.NotNull(e); Assert.True(e!.IsComment); Assert.True(e.IsDeleted); @@ -57,7 +57,7 @@ public class DhcpHostParserTests [Fact] public void ParseLine_TrailingComment_ParsesComment() { - var e = DhcpHostParser.ParseLine("dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop", 1); Assert.NotNull(e); Assert.Equal("main laptop", e!.Comment); Assert.Single(e.MacAddresses); @@ -66,7 +66,7 @@ public class DhcpHostParserTests [Fact] public void ParseLine_MultipleMacs_SameIp_Parses() { - var e = DhcpHostParser.ParseLine("dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2", 1); Assert.NotNull(e); Assert.Equal(2, e!.MacAddresses.Count); Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]); @@ -77,7 +77,7 @@ public class DhcpHostParserTests [Fact] public void ParseLine_WithSetTag_PutsInExtra() { - var e = DhcpHostParser.ParseLine("dhcp-host=AA:BB:CC:DD:CC:BB,redhost1,192.168.1.41,infinite,set:red", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=AA:BB:CC:DD:CC:BB,redhost1,192.168.1.41,infinite,set:red", 1); Assert.NotNull(e); Assert.Equal("192.168.1.41", e!.Address); Assert.Equal("redhost1", e.Name); @@ -87,7 +87,7 @@ public class DhcpHostParserTests [Fact] public void ParseLine_HostnameOnly_Parses() { - var e = DhcpHostParser.ParseLine("dhcp-host=lap,192.168.0.199", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=lap,192.168.0.199", 1); Assert.NotNull(e); Assert.Equal("lap", e!.Name); Assert.Equal("192.168.0.199", e.Address); @@ -97,7 +97,7 @@ public class DhcpHostParserTests [Fact] public void ParseLine_Ignore_Parses() { - var e = DhcpHostParser.ParseLine("dhcp-host=00:20:e0:3b:13:af,ignore", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=00:20:e0:3b:13:af,ignore", 1); Assert.NotNull(e); Assert.True(e!.Ignore); Assert.Single(e.MacAddresses); @@ -107,7 +107,7 @@ public class DhcpHostParserTests [Fact] public void ParseLine_NumericLease_Parses() { - var e = DhcpHostParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,pc,3600", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,pc,3600", 1); Assert.NotNull(e); Assert.Equal("3600", e!.Lease); } @@ -115,7 +115,7 @@ public class DhcpHostParserTests [Fact] public void ParseLine_WithIdClient_PutsInExtra() { - var e = DhcpHostParser.ParseLine("dhcp-host=id:01:02:03:04,192.168.1.50,myhost", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=id:01:02:03:04,192.168.1.50,myhost", 1); Assert.NotNull(e); Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase)); Assert.Equal("192.168.1.50", e.Address); @@ -125,9 +125,9 @@ public class DhcpHostParserTests public void ToLine_Roundtrip_Basic() { var line = "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"; - var e = DhcpHostParser.ParseLine(line, 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1); Assert.NotNull(e); - var back = DhcpHostParser.ToLine(e!); + var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); Assert.StartsWith("dhcp-host=", back); Assert.Contains("aa:bb:cc:dd:ee:ff", back); Assert.Contains("192.168.1.10", back); @@ -139,9 +139,9 @@ public class DhcpHostParserTests public void ToLine_Roundtrip_WithComment() { var line = "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop"; - var e = DhcpHostParser.ParseLine(line, 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1); Assert.NotNull(e); - var back = DhcpHostParser.ToLine(e!); + var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); Assert.StartsWith("dhcp-host=", back); Assert.Contains("main laptop", back); } @@ -149,18 +149,18 @@ public class DhcpHostParserTests [Fact] public void ToLine_CommentedEntry_Prefix() { - var e = DhcpHostParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc", 1); Assert.NotNull(e); - var back = DhcpHostParser.ToLine(e!); + var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); Assert.StartsWith("#dhcp-host=", back); } [Fact] public void ToLine_DeletedEntry_DoubleHash() { - var e = DhcpHostParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,old", 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,old", 1); Assert.NotNull(e); - var back = DhcpHostParser.ToLine(e!); + var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); Assert.StartsWith("##dhcp-host=", back); } @@ -171,7 +171,7 @@ public class DhcpHostParserTests var dhcpEntries = new List(); for (var i = 0; i < lines.Length; i++) { - var e = DhcpHostParser.ParseLine(lines[i], i + 1); + var e = DnsmasqConfDhcpHostLineParser.ParseLine(lines[i], i + 1); if (e != null) dhcpEntries.Add(e); } diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs new file mode 100644 index 0000000..ec7d505 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs @@ -0,0 +1,222 @@ +using DnsmasqWebUI.Models; +using DnsmasqWebUI.Parsers; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for DnsmasqConfDirectiveParser. Parses a single dnsmasq .conf line into DnsmasqConfDirective +/// with typed option (AddnHostsOption, DhcpLeaseFileOption, PathOption, RawOption, etc.). +/// +public class DnsmasqConfDirectiveParserTests +{ + const string SourcePath = "/etc/dnsmasq.d/example.conf"; + + [Fact] + public void ParseLine_Empty_ReturnsNull() + { + Assert.Null(DnsmasqConfDirectiveParser.ParseLine("", 1, SourcePath)); + Assert.Null(DnsmasqConfDirectiveParser.ParseLine(" ", 2, SourcePath)); + } + + [Fact] + public void ParseLine_Comment_ReturnsNull() + { + Assert.Null(DnsmasqConfDirectiveParser.ParseLine("# comment", 1, SourcePath)); + Assert.Null(DnsmasqConfDirectiveParser.ParseLine("# addn-hosts=/etc/hosts", 1, SourcePath)); + } + + [Fact] + public void ParseLine_AddnHosts_ReturnsAddnHostsOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("addn-hosts=/var/lib/dnsmasq/hosts", 3, SourcePath); + Assert.NotNull(d); + Assert.Equal(3, d!.LineNumber); + Assert.Equal(SourcePath, d.SourceFilePath); + Assert.Equal(DnsmasqOptionKind.AddnHosts, d.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("/var/lib/dnsmasq/hosts", opt.Path); + } + + [Fact] + public void ParseLine_AddnHosts_RelativePath_ResolvedAgainstSourceDir() + { + var conf = "/etc/dnsmasq.d/zz.conf"; + var d = DnsmasqConfDirectiveParser.ParseLine("addn-hosts=hosts.d/app.hosts", 1, conf); + Assert.NotNull(d); + var opt = Assert.IsType(d!.TypedOption); + Assert.Equal(Path.GetFullPath("/etc/dnsmasq.d/hosts.d/app.hosts"), opt.Path); + } + + [Fact] + public void ParseLine_DhcpLeasefile_ReturnsDhcpLeaseFileOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-leasefile=/var/lib/dnsmasq/dnsmasq.leases", 5, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("/var/lib/dnsmasq/dnsmasq.leases", opt.Path); + } + + [Fact] + public void ParseLine_DhcpLeaseFile_AlternativeKey_ReturnsDhcpLeaseFileOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-lease-file=/run/dnsmasq.leases", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("/run/dnsmasq.leases", opt.Path); + } + + [Fact] + public void ParseLine_Domain_ReturnsDomainOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("domain=local", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.Domain, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("local", opt.Value); + } + + [Fact] + public void ParseLine_ConfFile_ReturnsConfFileOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("conf-file=/etc/dnsmasq.d/extra.conf", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.ConfFile, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("/etc/dnsmasq.d/extra.conf", opt.Path); + } + + [Fact] + public void ParseLine_ConfDir_ReturnsConfDirOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("conf-dir=/etc/dnsmasq.d", 2, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.ConfDir, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal(Path.GetFullPath("/etc/dnsmasq.d"), opt.Path); + Assert.Null(opt.Suffix); + } + + [Fact] + public void ParseLine_ConfDir_WithSuffix_ReturnsConfDirOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("conf-dir=/etc/dnsmasq.d,.conf", 1, SourcePath); + Assert.NotNull(d); + var opt = Assert.IsType(d!.TypedOption); + Assert.Equal(Path.GetFullPath("/etc/dnsmasq.d"), opt.Path); + Assert.Equal(".conf", opt.Suffix); + } + + [Fact] + public void ParseLine_DhcpRange_ReturnsDhcpRangeOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-range=192.168.1.100,192.168.1.200,255.255.255.0,12h", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.DhcpRange, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("192.168.1.100,192.168.1.200,255.255.255.0,12h", opt.RawValue); + } + + [Fact] + public void ParseLine_ResolvFile_ReturnsPathOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("resolv-file=/etc/resolv.dnsmasq.conf", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.Path, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("resolv-file", opt.Key); + Assert.Equal("/etc/resolv.dnsmasq.conf", opt.Path); + } + + [Fact] + public void ParseLine_FlagOption_ReturnsRawOptionWithEmptyValue() + { + var d = DnsmasqConfDirectiveParser.ParseLine("domain-needed", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.Flag, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("domain-needed", opt.Key); + Assert.Equal("", opt.Value); + } + + [Fact] + public void ParseLine_UnknownOption_ReturnsRawOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("unknown-option=foo", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.Raw, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("unknown-option", opt.Key); + Assert.Equal("foo", opt.Value); + } + + [Fact] + public void ParseLine_KeyOnly_ReturnsRawOptionWithEmptyValue() + { + var d = DnsmasqConfDirectiveParser.ParseLine("some-flag", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.Raw, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("some-flag", opt.Key); + Assert.Equal("", opt.Value); + } + + [Fact] + public void ParseLine_CommentedLine_ReturnsNull() + { + Assert.Null(DnsmasqConfDirectiveParser.ParseLine("#addn-hosts=/etc/hosts", 1, SourcePath)); + } + + [Fact] + public void ParseLine_Server_ReturnsServerOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("server=8.8.8.8", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.Server, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("8.8.8.8", opt.RawValue); + } + + [Fact] + public void ParseLine_Local_ReturnsLocalOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("local=/localdomain/", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.Local, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("/localdomain/", opt.RawValue); + } + + [Fact] + public void ParseLine_Address_ReturnsAddressOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("address=/doubleclick.net/127.0.0.1", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.Address, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("/doubleclick.net/127.0.0.1", opt.RawValue); + } + + [Fact] + public void ParseLine_DhcpOption_ReturnsDhcpOptionOption() + { + var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-option=option:router,192.168.1.1", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.DhcpOption, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Equal("option:router,192.168.1.1", opt.RawValue); + } + + [Fact] + public void ParseLine_DhcpHost_ReturnsDhcpHostEntry() + { + var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1, SourcePath); + Assert.NotNull(d); + Assert.Equal(DnsmasqOptionKind.DhcpHost, d!.Kind); + var opt = Assert.IsType(d.TypedOption); + Assert.Single(opt.MacAddresses); + Assert.Equal("aa:bb:cc:dd:ee:ff", opt.MacAddresses[0]); + Assert.Equal("192.168.1.10", opt.Address); + } +} diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs new file mode 100644 index 0000000..cbc67e3 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs @@ -0,0 +1,186 @@ +using DnsmasqWebUI.Models; +using DnsmasqWebUI.Parsers; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for DnsmasqConfFileLineParser. Parses dnsmasq config file lines into Blank, Comment, AddnHosts, DhcpHost, Other. +/// +public class DnsmasqConfFileLineParserTests +{ + [Fact] + public void ParseFile_Empty_ReturnsEmpty() + { + var lines = DnsmasqConfFileLineParser.ParseFile([]); + Assert.Empty(lines); + } + + [Fact] + public void ParseFile_BlankLine_BlankKind() + { + var lines = DnsmasqConfFileLineParser.ParseFile([""]); + Assert.Single(lines); + var blank = Assert.IsType(lines[0]); + Assert.Equal(1, blank.LineNumber); + Assert.Equal("", blank.RawLine); + } + + [Fact] + public void ParseFile_BlankLineWithSpaces_BlankKind() + { + var lines = DnsmasqConfFileLineParser.ParseFile([" \t "]); + Assert.Single(lines); + var blank = Assert.IsType(lines[0]); + Assert.Equal(" \t ", blank.RawLine); + } + + [Fact] + public void ParseFile_CommentLine_CommentKind() + { + var lines = DnsmasqConfFileLineParser.ParseFile(["# Not managed by this app."]); + Assert.Single(lines); + var comment = Assert.IsType(lines[0]); + Assert.Equal(1, comment.LineNumber); + Assert.Equal("# Not managed by this app.", comment.RawLine); + } + + [Fact] + public void ParseFile_OtherDirective_OtherKind() + { + var lines = DnsmasqConfFileLineParser.ParseFile(["domain=local"]); + Assert.Single(lines); + var other = Assert.IsType(lines[0]); + Assert.Equal("domain=local", other.RawLine); + } + + [Fact] + public void ParseFile_AddnHostsLine_AddnHostsKind() + { + var lines = DnsmasqConfFileLineParser.ParseFile(["addn-hosts=/var/lib/dnsmasq/hosts"]); + Assert.Single(lines); + var addn = Assert.IsType(lines[0]); + Assert.Equal("/var/lib/dnsmasq/hosts", addn.AddnHostsPath); + } + + [Fact] + public void ToLine_AddnHosts_EmitsAddnHostsLine() + { + var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = "/etc/hosts" }; + Assert.Equal("addn-hosts=/etc/hosts", DnsmasqConfFileLineParser.ToLine(line)); + } + + [Fact] + public void ParseFile_DhcpHostLine_DhcpHostKind() + { + var lines = DnsmasqConfFileLineParser.ParseFile(["dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"]); + Assert.Single(lines); + var dhcpLine = Assert.IsType(lines[0]); + Assert.Single(dhcpLine.DhcpHost.MacAddresses); + Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]); + Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address); + } + + [Fact] + public void ParseFile_CommentedDhcpHost_TreatedAsComment() + { + // Parser treats any line starting with # as Comment (does not parse dhcp-host content) + var lines = DnsmasqConfFileLineParser.ParseFile(["#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc"]); + Assert.Single(lines); + Assert.Equal(DnsmasqConfLineKind.Comment, lines[0].Kind); + } + + [Fact] + public void ParseFile_MixedFile_ParsesAll() + { + var input = new[] + { + "# Sample config", + "", + "domain=local", + "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite", + "dhcp-range=192.168.1.100,192.168.1.200" + }; + var lines = DnsmasqConfFileLineParser.ParseFile(input); + Assert.Equal(5, lines.Count); + + Assert.IsType(lines[0]); + Assert.IsType(lines[1]); + var other2 = Assert.IsType(lines[2]); + Assert.Equal("domain=local", other2.RawLine); + var dhcp3 = Assert.IsType(lines[3]); + Assert.Equal("192.168.1.11", dhcp3.DhcpHost.Address); + var other4 = Assert.IsType(lines[4]); + Assert.Equal("dhcp-range=192.168.1.100,192.168.1.200", other4.RawLine); + } + + [Fact] + public void ToLine_Blank_PreservesRawLine() + { + var line = new BlankLine { LineNumber = 1, RawLine = " " }; + Assert.Equal(" ", DnsmasqConfFileLineParser.ToLine(line)); + } + + [Fact] + public void ToLine_Comment_PreservesRawLine() + { + var line = new CommentLine { LineNumber = 1, RawLine = "# comment" }; + Assert.Equal("# comment", DnsmasqConfFileLineParser.ToLine(line)); + } + + [Fact] + public void ToLine_Other_PreservesRawLine() + { + var line = new OtherLine { LineNumber = 1, RawLine = "domain=local" }; + Assert.Equal("domain=local", DnsmasqConfFileLineParser.ToLine(line)); + } + + [Fact] + public void ToLine_DhcpHost_SerializesViaDhcpHostLineParser() + { + var dhcp = new DhcpHostEntry + { + LineNumber = 1, + MacAddresses = ["aa:bb:cc:dd:ee:ff"], + Address = "192.168.1.10", + Name = "testpc", + Lease = "infinite" + }; + var line = new DhcpHostLine { LineNumber = 1, DhcpHost = dhcp }; + var back = DnsmasqConfFileLineParser.ToLine(line); + Assert.Equal("dhcp-host=aa:bb:cc:dd:ee:ff, testpc, 192.168.1.10, infinite", back); + } + + [Fact] + public void ParseFile_LineNumbers_Sequential() + { + var input = new[] { "a", "b", "c" }; + var lines = DnsmasqConfFileLineParser.ParseFile(input); + Assert.Equal(1, lines[0].LineNumber); + Assert.Equal(2, lines[1].LineNumber); + Assert.Equal(3, lines[2].LineNumber); + } + + [Fact] + public void ParseFile_testdata_dhcp_conf_ParsesCommentAndDhcpHost() + { + var input = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf"); + var lines = DnsmasqConfFileLineParser.ParseFile(input); + Assert.Equal(2, lines.Count); + Assert.IsType(lines[0]); + var dhcpLine = Assert.IsType(lines[1]); + Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]); + Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address); + Assert.Equal("testpc", dhcpLine.DhcpHost.Name); + } + + [Fact] + public void ParseFile_testdata_01_other_conf_ParsesCommentAndOther() + { + var input = TestDataHelper.ReadAllLines("dnsmasq.d/01-other.conf"); + var lines = DnsmasqConfFileLineParser.ParseFile(input); + Assert.Equal(2, lines.Count); + Assert.IsType(lines[0]); + var other = Assert.IsType(lines[1]); + Assert.Equal("domain=local", other.RawLine); + } +} diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs new file mode 100644 index 0000000..16bad06 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs @@ -0,0 +1,255 @@ +using DnsmasqWebUI.Parsers; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for DnsmasqConfIncludeParser. Discovers conf-file= and conf-dir= from main dnsmasq config +/// to build ordered list of included file paths and first conf-dir for managed file. +/// +public class DnsmasqConfIncludeParserTests +{ + [Fact] + public void GetIncludedPaths_MainFileMissing_ReturnsOnlyMainPath() + { + var main = Path.Combine(Path.GetTempPath(), "nonexistent-dnsmasq-" + Guid.NewGuid().ToString("N") + ".conf"); + var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); + Assert.Single(paths); + Assert.Equal(Path.GetFullPath(main), paths[0]); + } + + [Fact] + public void GetIncludedPaths_MainOnly_ReturnsSinglePath() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-main-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var main = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(main, "domain=local\n"); + var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); + Assert.Single(paths); + Assert.Equal(Path.GetFullPath(main), paths[0]); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetIncludedPaths_OneConfFile_ReturnsMainThenConfFile() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-conffile-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var includePath = Path.Combine(dir, "extra.conf"); + File.WriteAllText(includePath, "# extra\n"); + var main = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(main, "conf-file=extra.conf\n"); + var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); + Assert.Equal(2, paths.Count); + Assert.Equal(Path.GetFullPath(main), paths[0]); + Assert.Equal(Path.GetFullPath(includePath), paths[1]); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetIncludedPaths_OneConfDir_ReturnsMainThenDirFilesSorted() + { + var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(baseDir); + var subDir = Path.Combine(baseDir, "d"); + Directory.CreateDirectory(subDir); + try + { + File.WriteAllText(Path.Combine(subDir, "zz.conf"), ""); + File.WriteAllText(Path.Combine(subDir, "aa.conf"), ""); + var main = Path.Combine(baseDir, "dnsmasq.conf"); + File.WriteAllText(main, "conf-dir=d\n"); + var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); + Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical) + Assert.Equal(Path.GetFullPath(main), paths[0]); + Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "aa.conf")), paths[1]); + Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "zz.conf")), paths[2]); + } + finally + { + Directory.Delete(baseDir, recursive: true); + } + } + + [Fact] + public void GetFirstConfDir_NoConfDir_ReturnsNull() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-noconfdir-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var main = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(main, "domain=local\nconf-file=other.conf\n"); + var first = DnsmasqConfIncludeParser.GetFirstConfDir(main); + Assert.Null(first); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetFirstConfDir_HasConfDir_ReturnsResolvedPath() + { + var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(baseDir); + var subDir = Path.Combine(baseDir, "dnsmasq.d"); + Directory.CreateDirectory(subDir); + try + { + var main = Path.Combine(baseDir, "dnsmasq.conf"); + File.WriteAllText(main, "conf-dir=dnsmasq.d\n"); + var first = DnsmasqConfIncludeParser.GetFirstConfDir(main); + Assert.NotNull(first); + Assert.Equal(Path.GetFullPath(subDir), first); + } + finally + { + Directory.Delete(baseDir, recursive: true); + } + } + + [Fact] + public void GetIncludedPaths_CommentsAndBlanks_Ignored() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-comments-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var main = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(main, "# comment\n\n \nconf-file=extra.conf\n"); + var extra = Path.Combine(dir, "extra.conf"); + File.WriteAllText(extra, ""); + var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); + Assert.Equal(2, paths.Count); + Assert.Equal(Path.GetFullPath(extra), paths[1]); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetIncludedPaths_testdata_dnsmasq_conf_ReturnsMainOnlyWhenConfDirMissing() + { + var mainPath = TestDataHelper.GetPath("dnsmasq.conf"); + if (!File.Exists(mainPath)) + return; // testdata not copied + var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath); + Assert.Single(paths); + Assert.Equal(Path.GetFullPath(mainPath), paths[0]); + } + + [Fact] + public void GetDhcpLeaseFilePathFromConfigFiles_FromTestdata_ReturnsLeasesPath() + { + var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf"); + if (!File.Exists(mainPath)) + return; + var paths = new[] { mainPath }; + var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(paths); + Assert.NotNull(result); + Assert.Equal(Path.GetFullPath("/data/leases"), result); + } + + [Fact] + public void GetDhcpLeaseFilePathFromConfigFiles_LastWins() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var first = Path.Combine(dir, "first.conf"); + File.WriteAllText(first, "dhcp-leasefile=/var/first.leases\n"); + var second = Path.Combine(dir, "second.conf"); + File.WriteAllText(second, "dhcp-leasefile=/var/second.leases\n"); + var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second }); + Assert.Equal(Path.GetFullPath("/var/second.leases"), result); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetDhcpLeaseFilePathFromConfigFiles_RelativePath_ResolvedAgainstConfigDir() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-lease-rel-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var conf = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(conf, "dhcp-leasefile=subdir/leases\n"); + var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf }); + Assert.Equal(Path.GetFullPath(Path.Combine(dir, "subdir", "leases")), result); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetAddnHostsPathsFromConfigFiles_Empty_ReturnsEmpty() + { + var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(Array.Empty()); + Assert.Empty(result); + } + + [Fact] + public void GetAddnHostsPathsFromConfigFiles_Cumulative_ReturnsAllInOrder() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var first = Path.Combine(dir, "first.conf"); + File.WriteAllText(first, "addn-hosts=/etc/hosts.d/first\n"); + var second = Path.Combine(dir, "second.conf"); + File.WriteAllText(second, "addn-hosts=/data/hosts\naddn-hosts=/data/extra.hosts\n"); + var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second }); + Assert.Equal(3, result.Count); + Assert.Equal(Path.GetFullPath("/etc/hosts.d/first"), result[0]); + Assert.Equal(Path.GetFullPath("/data/hosts"), result[1]); + Assert.Equal(Path.GetFullPath("/data/extra.hosts"), result[2]); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetAddnHostsPathsFromConfigFiles_RelativePath_ResolvedAgainstConfigDir() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-rel-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var conf = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(conf, "addn-hosts=hosts.d/app.hosts\n"); + var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf }); + Assert.Single(result); + Assert.Equal(Path.GetFullPath(Path.Combine(dir, "hosts.d", "app.hosts")), result[0]); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs new file mode 100644 index 0000000..b495845 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs @@ -0,0 +1,94 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for DnsmasqConfOptionRegistry. Maps dnsmasq .conf option names to DnsmasqOptionKind. +/// +public class DnsmasqConfOptionRegistryTests +{ + [Fact] + public void GetKind_EmptyOrWhitespace_ReturnsRaw() + { + Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind("")); + Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind(" ")); + } + + [Fact] + public void GetKind_ConfFile_ReturnsConfFile() + { + Assert.Equal(DnsmasqOptionKind.ConfFile, DnsmasqConfOptionRegistry.GetKind("conf-file")); + Assert.Equal(DnsmasqOptionKind.ConfFile, DnsmasqConfOptionRegistry.GetKind("CONF-FILE")); + } + + [Fact] + public void GetKind_ConfDir_ReturnsConfDir() + { + Assert.Equal(DnsmasqOptionKind.ConfDir, DnsmasqConfOptionRegistry.GetKind("conf-dir")); + } + + [Fact] + public void GetKind_AddnHosts_ReturnsAddnHosts() + { + Assert.Equal(DnsmasqOptionKind.AddnHosts, DnsmasqConfOptionRegistry.GetKind("addn-hosts")); + } + + [Fact] + public void GetKind_DhcpLeasefile_ReturnsDhcpLeaseFile() + { + Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-leasefile")); + Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-lease-file")); + } + + [Fact] + public void GetKind_Domain_ReturnsDomain() + { + Assert.Equal(DnsmasqOptionKind.Domain, DnsmasqConfOptionRegistry.GetKind("domain")); + } + + [Fact] + public void GetKind_ResolvFile_ReturnsPath() + { + Assert.Equal(DnsmasqOptionKind.Path, DnsmasqConfOptionRegistry.GetKind("resolv-file")); + } + + [Fact] + public void GetKind_FlagOptions_ReturnsFlag() + { + Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("domain-needed")); + Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("bogus-priv")); + Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("no-hosts")); + Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("expand-hosts")); + Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("bind-interfaces")); + Assert.Equal(DnsmasqOptionKind.Flag, DnsmasqConfOptionRegistry.GetKind("log-queries")); + } + + [Fact] + public void GetKind_UnknownOption_ReturnsRaw() + { + Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind("unknown-option")); + Assert.Equal(DnsmasqOptionKind.Raw, DnsmasqConfOptionRegistry.GetKind("custom-key")); + } + + [Fact] + public void OptionKindByKey_ContainsExpectedOptions() + { + var registry = DnsmasqConfOptionRegistry.OptionKindByKey; + Assert.True(registry.ContainsKey("addn-hosts")); + Assert.True(registry.ContainsKey("dhcp-leasefile")); + Assert.True(registry.ContainsKey("dhcp-lease-file")); + Assert.True(registry.ContainsKey("conf-file")); + Assert.True(registry.ContainsKey("conf-dir")); + Assert.True(registry.ContainsKey("domain")); + Assert.True(registry.ContainsKey("resolv-file")); + } + + [Fact] + public void FlagOptions_ContainsExpectedFlags() + { + var flags = DnsmasqConfOptionRegistry.FlagOptions; + Assert.Contains("domain-needed", flags); + Assert.Contains("no-hosts", flags); + Assert.Contains("bind-interfaces", flags); + } +} diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfigParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfigParserTests.cs deleted file mode 100644 index 5958bd8..0000000 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfigParserTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using DnsmasqWebUI.Models; -using DnsmasqWebUI.Parsers; - -namespace DnsmasqWebUI.Tests; - -/// -/// Tests for DnsmasqConfigParser. Parses full config into Blank, Comment, DhcpHost, Other. -/// -public class DnsmasqConfigParserTests -{ - [Fact] - public void ParseFile_Empty_ReturnsEmpty() - { - var lines = DnsmasqConfigParser.ParseFile([]); - Assert.Empty(lines); - } - - [Fact] - public void ParseFile_BlankLine_BlankKind() - { - var lines = DnsmasqConfigParser.ParseFile([""]); - Assert.Single(lines); - Assert.Equal(ConfigLineKind.Blank, lines[0].Kind); - Assert.Equal(1, lines[0].LineNumber); - Assert.Equal("", lines[0].RawLine); - } - - [Fact] - public void ParseFile_BlankLineWithSpaces_BlankKind() - { - var lines = DnsmasqConfigParser.ParseFile([" \t "]); - Assert.Single(lines); - Assert.Equal(ConfigLineKind.Blank, lines[0].Kind); - Assert.Equal(" \t ", lines[0].RawLine); - } - - [Fact] - public void ParseFile_CommentLine_CommentKind() - { - var lines = DnsmasqConfigParser.ParseFile(["# Not managed by this app."]); - Assert.Single(lines); - Assert.Equal(ConfigLineKind.Comment, lines[0].Kind); - Assert.Equal(1, lines[0].LineNumber); - Assert.Equal("# Not managed by this app.", lines[0].RawLine); - } - - [Fact] - public void ParseFile_OtherDirective_OtherKind() - { - var lines = DnsmasqConfigParser.ParseFile(["domain=local"]); - Assert.Single(lines); - Assert.Equal(ConfigLineKind.Other, lines[0].Kind); - Assert.Equal("domain=local", lines[0].RawLine); - } - - [Fact] - public void ParseFile_DhcpHostLine_DhcpHostKind() - { - var lines = DnsmasqConfigParser.ParseFile(["dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"]); - Assert.Single(lines); - Assert.Equal(ConfigLineKind.DhcpHost, lines[0].Kind); - var dhcp0 = lines[0].DhcpHost; - Assert.NotNull(dhcp0); - Assert.Single(dhcp0.MacAddresses); - Assert.Equal("aa:bb:cc:dd:ee:ff", dhcp0.MacAddresses[0]); - Assert.Equal("192.168.1.10", dhcp0.Address); - } - - [Fact] - public void ParseFile_CommentedDhcpHost_TreatedAsComment() - { - // Parser treats any line starting with # as Comment (does not parse dhcp-host content) - var lines = DnsmasqConfigParser.ParseFile(["#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc"]); - Assert.Single(lines); - Assert.Equal(ConfigLineKind.Comment, lines[0].Kind); - } - - [Fact] - public void ParseFile_MixedFile_ParsesAll() - { - var input = new[] - { - "# Sample config", - "", - "domain=local", - "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite", - "dhcp-range=192.168.1.100,192.168.1.200" - }; - var lines = DnsmasqConfigParser.ParseFile(input); - Assert.Equal(5, lines.Count); - - Assert.Equal(ConfigLineKind.Comment, lines[0].Kind); - Assert.Equal(ConfigLineKind.Blank, lines[1].Kind); - Assert.Equal(ConfigLineKind.Other, lines[2].Kind); - Assert.Equal("domain=local", lines[2].RawLine); - Assert.Equal(ConfigLineKind.DhcpHost, lines[3].Kind); - var dhcp3 = lines[3].DhcpHost; - Assert.NotNull(dhcp3); - Assert.Equal("192.168.1.11", dhcp3.Address); - Assert.Equal(ConfigLineKind.Other, lines[4].Kind); - Assert.Equal("dhcp-range=192.168.1.100,192.168.1.200", lines[4].RawLine); - } - - [Fact] - public void ToLine_Blank_PreservesRawLine() - { - var line = new ConfigLine { Kind = ConfigLineKind.Blank, LineNumber = 1, RawLine = " " }; - Assert.Equal(" ", DnsmasqConfigParser.ToLine(line)); - } - - [Fact] - public void ToLine_Comment_PreservesRawLine() - { - var line = new ConfigLine { Kind = ConfigLineKind.Comment, LineNumber = 1, RawLine = "# comment" }; - Assert.Equal("# comment", DnsmasqConfigParser.ToLine(line)); - } - - [Fact] - public void ToLine_Other_PreservesRawLine() - { - var line = new ConfigLine { Kind = ConfigLineKind.Other, LineNumber = 1, RawLine = "domain=local" }; - Assert.Equal("domain=local", DnsmasqConfigParser.ToLine(line)); - } - - [Fact] - public void ToLine_DhcpHost_SerializesViaDhcpHostParser() - { - var dhcp = new DhcpHostEntry - { - LineNumber = 1, - MacAddresses = ["aa:bb:cc:dd:ee:ff"], - Address = "192.168.1.10", - Name = "testpc", - Lease = "infinite" - }; - var line = new ConfigLine { Kind = ConfigLineKind.DhcpHost, LineNumber = 1, DhcpHost = dhcp }; - var back = DnsmasqConfigParser.ToLine(line); - Assert.Equal("dhcp-host=aa:bb:cc:dd:ee:ff, testpc, 192.168.1.10, infinite", back); - } - - [Fact] - public void ParseFile_LineNumbers_Sequential() - { - var input = new[] { "a", "b", "c" }; - var lines = DnsmasqConfigParser.ParseFile(input); - Assert.Equal(1, lines[0].LineNumber); - Assert.Equal(2, lines[1].LineNumber); - Assert.Equal(3, lines[2].LineNumber); - } - - [Fact] - public void ParseFile_testdata_dhcp_conf_ParsesCommentAndDhcpHost() - { - var input = TestDataHelper.ReadAllLines("dnsmasq.d/dhcp.conf"); - var lines = DnsmasqConfigParser.ParseFile(input); - Assert.Equal(2, lines.Count); - Assert.Equal(ConfigLineKind.Comment, lines[0].Kind); - Assert.Equal(ConfigLineKind.DhcpHost, lines[1].Kind); - var dhcp = lines[1].DhcpHost; - Assert.NotNull(dhcp); - Assert.Equal("aa:bb:cc:dd:ee:ff", dhcp.MacAddresses[0]); - Assert.Equal("192.168.1.10", dhcp.Address); - Assert.Equal("testpc", dhcp.Name); - } - - [Fact] - public void ParseFile_testdata_01_other_conf_ParsesCommentAndOther() - { - var input = TestDataHelper.ReadAllLines("dnsmasq.d/01-other.conf"); - var lines = DnsmasqConfigParser.ParseFile(input); - Assert.Equal(2, lines.Count); - Assert.Equal(ConfigLineKind.Comment, lines[0].Kind); - Assert.Equal(ConfigLineKind.Other, lines[1].Kind); - Assert.Equal("domain=local", lines[1].RawLine); - } -} diff --git a/src/DnsmasqWebUI.Tests/LeasesParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqLeasesFileLineParserTests.cs similarity index 65% rename from src/DnsmasqWebUI.Tests/LeasesParserTests.cs rename to src/DnsmasqWebUI.Tests/DnsmasqLeasesFileLineParserTests.cs index ddad6a2..d976604 100644 --- a/src/DnsmasqWebUI.Tests/LeasesParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqLeasesFileLineParserTests.cs @@ -4,22 +4,22 @@ using DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Tests; /// -/// Tests for LeasesParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated +/// Tests for DnsmasqLeasesFileLineParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated /// fields — expiry (epoch), MAC, IP, hostname (or *), client-id (or *). /// -public class LeasesParserTests +public class DnsmasqLeasesFileLineParserTests { [Fact] public void ParseLine_Empty_ReturnsNull() { - Assert.Null(LeasesParser.ParseLine("")); - Assert.Null(LeasesParser.ParseLine(" ")); + Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("")); + Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(" ")); } [Fact] public void ParseLine_ValidFiveFields_Parses() { - var e = LeasesParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"); + var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"); Assert.NotNull(e); Assert.Equal(946689575, e!.Epoch); Assert.Equal("00:00:00:00:00:05", e.Mac); @@ -31,7 +31,7 @@ public class LeasesParserTests [Fact] public void ParseLine_UnknownHostname_Asterisk() { - var e = LeasesParser.ParseLine("946689522 00:00:00:00:00:04 192.168.1.237 * 01:00:00:00:00:00:04"); + var e = DnsmasqLeasesFileLineParser.ParseLine("946689522 00:00:00:00:00:04 192.168.1.237 * 01:00:00:00:00:00:04"); Assert.NotNull(e); Assert.Equal("*", e!.Name); Assert.Equal("192.168.1.237", e.Address); @@ -40,7 +40,7 @@ public class LeasesParserTests [Fact] public void ParseLine_UnknownClientId_Asterisk() { - var e = LeasesParser.ParseLine("946689351 00:0f:b0:3a:b5:0b 192.168.1.208 colinux *"); + var e = DnsmasqLeasesFileLineParser.ParseLine("946689351 00:0f:b0:3a:b5:0b 192.168.1.208 colinux *"); Assert.NotNull(e); Assert.Equal("colinux", e!.Name); Assert.Equal("*", e.ClientId); @@ -49,7 +49,7 @@ public class LeasesParserTests [Fact] public void ParseLine_MultipleSpacesBetweenFields_Parses() { - var e = LeasesParser.ParseLine(" 946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 "); + var e = DnsmasqLeasesFileLineParser.ParseLine(" 946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 "); Assert.NotNull(e); Assert.Equal(946689575, e!.Epoch); Assert.Equal("00:00:00:00:00:05", e.Mac); @@ -61,21 +61,21 @@ public class LeasesParserTests [Fact] public void ParseLine_TooFewFields_ReturnsNull() { - Assert.Null(LeasesParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155")); - Assert.Null(LeasesParser.ParseLine("946689575 00:00:00:00:00:05")); + Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155")); + Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05")); } [Fact] public void ParseLine_TrailingJunk_ReturnsNull() { // Parser uses .End() so extra text after fifth field fails - Assert.Null(LeasesParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 extra")); + Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 extra")); } [Fact] public void ParseLine_InvalidEpoch_ReturnsNull() { - Assert.Null(LeasesParser.ParseLine("notanum 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05")); + Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("notanum 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05")); } /// @@ -84,13 +84,13 @@ public class LeasesParserTests [Fact] public void ParseLine_DuidLine_ReturnsNull() { - Assert.Null(LeasesParser.ParseLine("duid 00:11:22:33:44:55")); + Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("duid 00:11:22:33:44:55")); } [Fact] public void Timestamp_ConvertsEpochToDateTime() { - var e = LeasesParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"); + var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"); Assert.NotNull(e); var dt = e!.Timestamp; Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12 @@ -103,7 +103,7 @@ public class LeasesParserTests var entries = new List(); foreach (var line in lines) { - var e = LeasesParser.ParseLine(line); + var e = DnsmasqLeasesFileLineParser.ParseLine(line); if (e != null) entries.Add(e); } diff --git a/src/DnsmasqWebUI.Tests/HostsParserTests.cs b/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs similarity index 76% rename from src/DnsmasqWebUI.Tests/HostsParserTests.cs rename to src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs index 841cdb6..bb5b562 100644 --- a/src/DnsmasqWebUI.Tests/HostsParserTests.cs +++ b/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs @@ -4,15 +4,15 @@ using DnsmasqWebUI.Parsers; namespace DnsmasqWebUI.Tests; /// -/// Tests for HostsParser. Format: IP_address canonical_hostname [aliases...] (hosts(5), RFC 952). +/// Tests for HostsFileLineParser. Format: IP_address canonical_hostname [aliases...] (hosts(5), RFC 952). /// Fields separated by blanks/tabs; '#' to EOL is comment. /// -public class HostsParserTests +public class HostsFileLineParserTests { [Fact] public void ParseLine_Blank_ReturnsPassthrough() { - var e = HostsParser.ParseLine("", 1); + var e = HostsFileLineParser.ParseLine("", 1); Assert.NotNull(e); Assert.True(e!.IsPassthrough); Assert.Equal(1, e.LineNumber); @@ -22,7 +22,7 @@ public class HostsParserTests [Fact] public void ParseLine_WhitespaceOnly_ReturnsPassthrough() { - var e = HostsParser.ParseLine(" \t ", 2); + var e = HostsFileLineParser.ParseLine(" \t ", 2); Assert.NotNull(e); Assert.True(e!.IsPassthrough); } @@ -30,7 +30,7 @@ public class HostsParserTests [Fact] public void ParseLine_CommentOnly_TreatsWholeLineAsComment() { - var e = HostsParser.ParseLine("# This is a comment", 1); + var e = HostsFileLineParser.ParseLine("# This is a comment", 1); Assert.NotNull(e); Assert.True(e!.IsComment); Assert.True(e.IsPassthrough); @@ -42,7 +42,7 @@ public class HostsParserTests public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress() { // Per hosts(5): text from # to EOL is comment - so "# 127.0.0.1 localhost" is entirely comment - var e = HostsParser.ParseLine("# 127.0.0.1 localhost", 1); + var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1); Assert.NotNull(e); Assert.True(e!.IsComment); Assert.Equal("", e.Address); @@ -52,7 +52,7 @@ public class HostsParserTests [Fact] public void ParseLine_IPv4Localhost_ParsesAddressAndName() { - var e = HostsParser.ParseLine("127.0.0.1 localhost", 1); + var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost", 1); Assert.NotNull(e); Assert.False(e!.IsPassthrough); Assert.Equal("127.0.0.1", e.Address); @@ -63,7 +63,7 @@ public class HostsParserTests [Fact] public void ParseLine_IPv4WithCanonicalAndAlias_ParsesAll() { - var e = HostsParser.ParseLine("127.0.1.1 thishost.example.org thishost", 1); + var e = HostsFileLineParser.ParseLine("127.0.1.1 thishost.example.org thishost", 1); Assert.NotNull(e); Assert.Equal("127.0.1.1", e.Address); Assert.True(e.Names.Count >= 1); @@ -75,7 +75,7 @@ public class HostsParserTests [Fact] public void ParseLine_IPv4WithMultipleSpaces_ParsesCorrectly() { - var e = HostsParser.ParseLine("192.168.1.10 foo.example.org foo", 1); + var e = HostsFileLineParser.ParseLine("192.168.1.10 foo.example.org foo", 1); Assert.NotNull(e); Assert.Equal("192.168.1.10", e.Address); Assert.True(e.Names.Count >= 1); @@ -85,7 +85,7 @@ public class HostsParserTests [Fact] public void ParseLine_IPv6_ParsesAddressAndNames() { - var e = HostsParser.ParseLine("::1 localhost ip6-localhost ip6-loopback", 1); + var e = HostsFileLineParser.ParseLine("::1 localhost ip6-localhost ip6-loopback", 1); Assert.NotNull(e); Assert.Equal("::1", e.Address); Assert.True(e.Names.Count >= 1); @@ -95,7 +95,7 @@ public class HostsParserTests [Fact] public void ParseLine_IPv6Multicast_Parses() { - var e = HostsParser.ParseLine("ff02::1 ip6-allnodes", 1); + var e = HostsFileLineParser.ParseLine("ff02::1 ip6-allnodes", 1); Assert.NotNull(e); Assert.Equal("ff02::1", e.Address); Assert.Single(e.Names); @@ -106,7 +106,7 @@ public class HostsParserTests public void ParseLine_InlineComment_StopsAtHash() { // Token stops at #; only address and names before # are parsed - var e = HostsParser.ParseLine("127.0.0.1 localhost # loopback", 1); + var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost # loopback", 1); Assert.NotNull(e); Assert.Equal("127.0.0.1", e.Address); Assert.Single(e.Names); @@ -116,7 +116,7 @@ public class HostsParserTests [Fact] public void ParseLine_LeadingWhitespace_TrimmedAndParsed() { - var e = HostsParser.ParseLine(" 192.168.1.13 bar", 1); + var e = HostsFileLineParser.ParseLine(" 192.168.1.13 bar", 1); Assert.NotNull(e); Assert.Equal("192.168.1.13", e.Address); Assert.Single(e.Names); @@ -127,7 +127,7 @@ public class HostsParserTests public void ParseLine_UnparseableLine_ReturnsPassthrough() { // Line with no whitespace between tokens fails Content (address + space + names) - var e = HostsParser.ParseLine("garbage-with-no-space", 1); + var e = HostsFileLineParser.ParseLine("garbage-with-no-space", 1); Assert.NotNull(e); Assert.True(e!.IsPassthrough); } @@ -136,7 +136,7 @@ public class HostsParserTests public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough() { // Content requires address + whitespace + at least one name - var e = HostsParser.ParseLine("192.168.1.1", 1); + var e = HostsFileLineParser.ParseLine("192.168.1.1", 1); Assert.NotNull(e); Assert.True(e!.IsPassthrough); } @@ -145,20 +145,20 @@ public class HostsParserTests public void ToLine_Entry_Roundtrips() { var line = "127.0.0.1 localhost"; - var e = HostsParser.ParseLine(line, 1); + var e = HostsFileLineParser.ParseLine(line, 1); Assert.NotNull(e); - var back = HostsParser.ToLine(e!); + var back = HostsFileLineParser.ToLine(e!); Assert.Equal(line, back); } [Fact] public void ToLine_CommentedEntry_PrefixPreserved() { - var e = HostsParser.ParseLine("# 127.0.0.1 localhost", 1); + var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1); Assert.NotNull(e); // When we parse "# ..." we get isComment=true, address="", names=[]. ToLine for passthrough returns RawLine. e = new HostEntry { LineNumber = 1, Address = "127.0.0.1", Names = ["localhost"], IsComment = true }; - var back = HostsParser.ToLine(e); + var back = HostsFileLineParser.ToLine(e); Assert.Equal("# 127.0.0.1 localhost", back); } @@ -166,7 +166,7 @@ public class HostsParserTests public void ToLine_Passthrough_ReturnsRawLine() { var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true }; - Assert.Equal(" \t ", HostsParser.ToLine(e)); + Assert.Equal(" \t ", HostsFileLineParser.ToLine(e)); } [Fact] @@ -176,7 +176,7 @@ public class HostsParserTests Assert.True(lines.Length >= 2, "testdata/hosts should have at least 2 lines"); var entries = new List(); for (var i = 0; i < lines.Length; i++) - entries.Add(HostsParser.ParseLine(lines[i], i + 1)); + entries.Add(HostsFileLineParser.ParseLine(lines[i], i + 1)); var dataEntries = entries.Where(e => e != null && !e.IsPassthrough).ToList(); Assert.Equal(2, dataEntries.Count); diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor index e77a65b..3aeaafe 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor @@ -10,7 +10,7 @@