package policy import ( "net/netip" "testing" "github.com/owen/vibedns/internal/blacklist" "github.com/owen/vibedns/internal/models" ) func buildSet(id int64, name, kind string, domains map[string]bool) *blacklist.Set { b := blacklist.NewBuilder(id, name, kind, len(domains)) for d, sub := range domains { b.Add(d, sub) } return b.Build() } // testIndex mirrors the example in the brief: a guest network with three // blacklists, a secure LAN with malware only, and a global allowlist. func testIndex(t *testing.T) *Index { t.Helper() malware := buildSet(1, "Malware", models.KindBlacklist, map[string]bool{ "evil.example": true, "c2.example.net": true, }) adult := buildSet(2, "Adult Content", models.KindBlacklist, map[string]bool{ "adult.example": true, }) gambling := buildSet(3, "Gambling", models.KindBlacklist, map[string]bool{ "bet.example": true, }) allow := buildSet(4, "Global Allowlist", models.KindAllowlist, map[string]bool{ "safe.adult.example": false, // exact match only }) lists := map[int64]*blacklist.Set{1: malware, 2: adult, 3: gambling, 4: allow} policies := []models.Policy{ { ID: 10, Name: "Guest Filtering", Enabled: true, BlockAction: models.BlockNXDOMAIN, BlockTTL: 60, BlacklistIDs: []int64{1, 2, 3}, AllowlistIDs: []int64{4}, SinkholeIPv4: "0.0.0.0", SinkholeIPv6: "::", }, { ID: 11, Name: "Malware Only", Enabled: true, BlockAction: models.BlockSinkhole, BlockTTL: 30, BlacklistIDs: []int64{1}, SinkholeIPv4: "192.0.2.1", SinkholeIPv6: "2001:db8::1", }, { ID: 12, Name: "Disabled Policy", Enabled: false, BlockAction: models.BlockRefused, BlacklistIDs: []int64{1, 2, 3}, }, } networks := []models.Network{ {ID: 100, Name: "Guest Wi-Fi", CIDR: "100.64.30.0/24", Enabled: true}, {ID: 101, Name: "SecureLAN", CIDR: "100.64.10.0/24", Enabled: true}, {ID: 102, Name: "Broad", CIDR: "100.64.0.0/16", Enabled: true}, {ID: 103, Name: "Disabled Net", CIDR: "10.9.0.0/16", Enabled: false}, {ID: 104, Name: "IPv6 LAN", CIDR: "2001:db8:1::/48", Enabled: true}, } assignments := map[int64][]int64{ 100: {10}, 101: {11}, 102: {12}, // only a disabled policy 103: {10}, 104: {11}, } return Build(networks, assignments, policies, lists) } func addr(t *testing.T, s string) netip.Addr { t.Helper() a, err := netip.ParseAddr(s) if err != nil { t.Fatalf("bad test address %q: %v", s, err) } return a } func TestMatchNetworkPrefersMostSpecific(t *testing.T) { idx := testIndex(t) tests := []struct { ip string want string }{ {"100.64.30.5", "Guest Wi-Fi"}, // /24 beats the enclosing /16 {"100.64.10.5", "SecureLAN"}, {"100.64.99.5", "Broad"}, // only the /16 covers it {"10.9.0.1", ""}, // network is disabled {"203.0.113.1", ""}, // no network covers it {"2001:db8:1::5", "IPv6 LAN"}, } for _, tc := range tests { t.Run(tc.ip, func(t *testing.T) { n := idx.MatchNetwork(addr(t, tc.ip)) got := "" if n != nil { got = n.Name } if got != tc.want { t.Errorf("network for %s = %q, want %q", tc.ip, got, tc.want) } }) } } func TestEvaluateBlocking(t *testing.T) { idx := testIndex(t) tests := []struct { name string client string qname string wantBlocked bool wantList string wantAction models.BlockAction }{ {"guest blocked by malware", "100.64.30.5", "evil.example", true, "Malware", models.BlockNXDOMAIN}, {"guest blocked by adult", "100.64.30.5", "adult.example", true, "Adult Content", models.BlockNXDOMAIN}, {"guest blocked by gambling", "100.64.30.5", "bet.example", true, "Gambling", models.BlockNXDOMAIN}, {"guest subdomain blocked", "100.64.30.5", "www.adult.example", true, "Adult Content", models.BlockNXDOMAIN}, {"guest deep subdomain blocked", "100.64.30.5", "a.b.c.adult.example", true, "Adult Content", models.BlockNXDOMAIN}, {"guest clean name allowed", "100.64.30.5", "example.org", false, "", ""}, {"lan blocked by malware", "100.64.10.5", "evil.example", true, "Malware", models.BlockSinkhole}, {"lan not filtered for adult", "100.64.10.5", "adult.example", false, "", ""}, {"lan not filtered for gambling", "100.64.10.5", "bet.example", false, "", ""}, {"disabled policy filters nothing", "100.64.99.5", "evil.example", false, "", ""}, {"unknown client is unfiltered", "203.0.113.1", "evil.example", false, "", ""}, {"ipv6 client uses its policy", "2001:db8:1::5", "c2.example.net", true, "Malware", models.BlockSinkhole}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { d := idx.Evaluate(addr(t, tc.client), tc.qname) if d.Blocked != tc.wantBlocked { t.Fatalf("blocked = %v, want %v", d.Blocked, tc.wantBlocked) } if !tc.wantBlocked { return } if d.ListName != tc.wantList { t.Errorf("list = %q, want %q", d.ListName, tc.wantList) } if d.Action() != tc.wantAction { t.Errorf("action = %q, want %q", d.Action(), tc.wantAction) } }) } } // TestAllowlistOverridesBlacklist is the rule that lets an operator carve an // exception out of a large imported blocklist without editing it. func TestAllowlistOverridesBlacklist(t *testing.T) { idx := testIndex(t) client := addr(t, "100.64.30.5") // safe.adult.example is on the allowlist even though adult.example (and // therefore all of its subdomains) is blacklisted. d := idx.Evaluate(client, "safe.adult.example") if d.Blocked { t.Errorf("allowlisted name was blocked by %q", d.ListName) } if !d.Allowed { t.Error("expected the decision to record an explicit allow") } // The allowlist entry is exact-only, so a sibling stays blocked. if d := idx.Evaluate(client, "other.adult.example"); !d.Blocked { t.Error("an exact-only allowlist entry must not cover sibling names") } } func TestQueryNameNormalisation(t *testing.T) { idx := testIndex(t) client := addr(t, "100.64.30.5") for _, name := range []string{"evil.example", "evil.example.", "EVIL.EXAMPLE", "Evil.Example."} { if d := idx.Evaluate(client, name); !d.Blocked { t.Errorf("%q was not blocked; names must match regardless of case or trailing dot", name) } } } func TestIPv4MappedClientAddress(t *testing.T) { idx := testIndex(t) // A UDP socket on a dual-stack listener reports IPv4 clients in the // ::ffff:a.b.c.d form; it must still match an IPv4 network. mapped := netip.MustParseAddr("::ffff:100.64.30.5") n := idx.MatchNetwork(mapped) if n == nil || n.Name != "Guest Wi-Fi" { t.Errorf("IPv4-mapped address matched %v, want Guest Wi-Fi", n) } } func TestStats(t *testing.T) { idx := testIndex(t) s := idx.Stats() if s.Networks != 4 { t.Errorf("networks = %d, want 4 enabled", s.Networks) } if s.Policies != 2 { t.Errorf("policies = %d, want 2 enabled", s.Policies) } if s.BlockedDomains != 4 { t.Errorf("blocked domains = %d, want 4", s.BlockedDomains) } if s.AllowedDomains != 1 { t.Errorf("allowed domains = %d, want 1", s.AllowedDomains) } } func TestNilIndexIsSafe(t *testing.T) { var idx *Index if d := idx.Evaluate(netip.MustParseAddr("192.0.2.1"), "example.com"); d.Blocked { t.Error("a nil index must not block anything") } if idx.MatchNetwork(netip.MustParseAddr("192.0.2.1")) != nil { t.Error("a nil index must match no network") } }