more stuff
build / build-push (push) Successful in 1m11s

This commit is contained in:
mats committed 2026-10-16 14:49:50 +02:00
1 parent 289a8cc2d8
commit 66f36cfac6
19 files changed
+1905 -817

No files matched your search

+6
View File
@@ -327,6 +327,12 @@ func matchPTR(n, suffix string) bool {
if suffix == "" || len(n) < len(suffix) || !strings.HasSuffix(n, suffix) {
return false
}
// A suffix written with its own leading dot (".lan.") is the natural way to
// say "anything under lan" and the console's own hint suggests it. Its dot
// is the label boundary, so there is nothing more to check.
if suffix[0] == '.' {
return true
}
// The suffix has to start a label: it is either the whole name, or the
// character in front of it is a dot.
rest := n[:len(n)-len(suffix)]
+6
View File
@@ -381,6 +381,12 @@ func TestMatchPTRLabelBoundary(t *testing.T) {
{"empty suffix", "host.example.com.", "", false},
{"empty name", "", "example.com.", false},
{"a hyphen is not a label boundary", "host-example.com.", "example.com.", false},
// The console suggests ".lan" as a suffix; the leading dot was being
// counted as part of the label, so it never matched anything.
{"leading-dot suffix matches a name under it", "host.lan.", ".lan.", true},
{"leading-dot suffix matches deeper names", "a.b.lan.", ".lan.", true},
{"leading-dot suffix does not match the apex", "lan.", ".lan.", false},
{"leading-dot suffix still respects the label", "hostlan.", ".lan.", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
+414
View File
@@ -0,0 +1,414 @@
package acl
import (
"fmt"
"net/netip"
"strings"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// Finding is one observation about a group, tied to the field it concerns so
// an editor can show it next to the input.
type Finding struct {
Field string `json:"field"`
Message string `json:"message"`
}
// Analysis is what Analyze says about a group that is structurally valid.
//
// It exists for people who do not know what the settings do. The engine's
// behaviour is not always what a setting's name suggests (an allow list is an
// allowlist-only mode, a rate limit of 0 exempts the group from the default),
// so the analysis states the effective behaviour in plain words and flags the
// settings that are legal but almost certainly not what was meant.
type Analysis struct {
// Summary is the group in plain English, one sentence per line.
Summary []string `json:"summary"`
// Warnings are legal settings that probably do not do what was intended.
Warnings []Finding `json:"warnings"`
// Notes are facts worth knowing that are not mistakes.
Notes []Finding `json:"notes"`
// Unreachable is set when no client can ever land in the group, because
// the groups above it claim everything it selects.
Unreachable bool `json:"unreachable"`
}
// Context is the group under review and everything around it.
type Context struct {
// Groups is the full evaluation order, with the group under review in it.
Groups []config.Group
// Index is the position of the group under review within Groups.
Index int
// DefaultRateLimit is acl.default_rate_limit (queries/second/client).
DefaultRateLimit int
// DefaultPolicy is what a client that matches no group gets.
DefaultPolicy string
// Upstreams are the configured upstream addresses. Nil means unknown, and
// the upstream check is skipped.
Upstreams []string
}
// Analyze explains Groups[Index]. It does not validate: a group that fails
// validation is reported by the caller's own checks, and Analyze copes with
// whatever it is handed.
func Analyze(c Context) Analysis {
a := Analysis{Summary: []string{}, Warnings: []Finding{}, Notes: []Finding{}}
if c.Index < 0 || c.Index >= len(c.Groups) {
return a
}
g := c.Groups[c.Index]
warn := func(field, format string, args ...any) {
a.Warnings = append(a.Warnings, Finding{field, fmt.Sprintf(format, args...)})
}
note := func(field, format string, args ...any) {
a.Notes = append(a.Notes, Finding{field, fmt.Sprintf(format, args...)})
}
a.Summary = append(a.Summary, whoSentence(g), c.orderSentence())
// What the group does.
switch g.Policy {
case "deny":
a.Summary = append(a.Summary, "Every query from these clients is refused (REFUSED). Nothing below applies to them.")
for _, ignored := range []struct {
field string
set bool
}{
{"allow_domains", len(g.AllowDomains) > 0}, {"deny_domains", len(g.DenyDomains) > 0},
{"skip_blocklist", g.SkipBlocklist}, {"skip_cache", g.SkipCache},
{"rate_limit", g.RateLimit > 0}, {"max_concurrent", g.MaxConcurrent > 0},
{"upstreams", len(g.Upstreams) > 0},
} {
if ignored.set {
note(ignored.field, "Ignored: this group's policy is deny, which refuses the query before anything else is looked at.")
}
}
case "block_only":
a.Summary = append(a.Summary, "Queries are resolved normally, and names on a blocklist are blocked.")
warn("policy", "block_only currently behaves exactly like allow: both resolve everything and both apply the blocklists. Only deny changes what the group can do. Pick allow unless you are keeping block_only for a reason.")
default:
a.Summary = append(a.Summary, "Queries are resolved normally, and names on a blocklist are blocked.")
}
if g.Policy != "deny" {
a.analyzeBehaviour(c, g, warn, note)
}
a.analyzeMatch(g, warn, note)
a.analyzeDomains(g, warn, note)
a.analyzeOrder(c, warn)
return a
}
func (a *Analysis) analyzeBehaviour(c Context, g config.Group, warn, note func(string, string, ...any)) {
if len(g.AllowDomains) > 0 {
a.Summary = append(a.Summary, fmt.Sprintf("Allowlist mode: ONLY %s can be looked up. Every other name is answered as blocked.", joinShort(g.AllowDomains)))
note("allow_domains", "A non-empty allow list turns the group into allowlist-only mode. It does not exempt names from the blocklists; use \"Skip blocklists\" for that.")
}
if len(g.DenyDomains) > 0 {
a.Summary = append(a.Summary, fmt.Sprintf("%s is always blocked for these clients, whatever the blocklists say.", joinShort(g.DenyDomains)))
}
if g.SkipBlocklist {
a.Summary = append(a.Summary, "Blocklists are ignored for these clients: ads and trackers resolve normally.")
}
if g.SkipCache {
a.Summary = append(a.Summary, "Nothing is served from or stored in the cache for these clients; every query goes upstream.")
note("skip_cache", "Every query from this group costs an upstream round trip, so it is slower and adds load. Use it for debugging, not as a default.")
}
switch {
case g.RateLimit > 0:
a.Summary = append(a.Summary, fmt.Sprintf("Each client address may send %d queries per second; above that, queries are refused.", g.RateLimit))
case c.DefaultRateLimit > 0:
a.Summary = append(a.Summary, "There is no rate limit for these clients.")
warn("rate_limit", "0 means unlimited here, not \"use the default\": this group is exempt from the default limit of %d queries per second per client.", c.DefaultRateLimit)
}
if g.MaxConcurrent > 0 {
a.Summary = append(a.Summary, fmt.Sprintf("A client may have at most %d queries in flight; the excess get SERVFAIL.", g.MaxConcurrent))
}
if g.LogQueries {
a.Summary = append(a.Summary, "Every query from these clients is written to the access log, even when logging is otherwise sampled or limited to blocked queries.")
}
if len(g.Upstreams) > 0 {
a.Summary = append(a.Summary, fmt.Sprintf("Queries go to %s instead of the default upstreams.", joinShort(g.Upstreams)))
note("upstreams", "Conditional-forwarding zones (upstream.forward_zones) still take precedence over a group's upstreams.")
if c.Upstreams != nil {
known := map[string]bool{}
for _, u := range c.Upstreams {
known[strings.ToLower(u)] = true
}
var unknown []string
for _, u := range g.Upstreams {
if !known[strings.ToLower(u)] {
unknown = append(unknown, u)
}
}
switch {
case len(unknown) == len(g.Upstreams):
warn("upstreams", "None of these addresses is a configured upstream, so the setting is ignored and this group uses ALL upstreams. Use an address exactly as it appears on the Upstreams page.")
case len(unknown) > 0:
warn("upstreams", "%s is not a configured upstream and is ignored.", joinShort(unknown))
}
}
}
}
func (a *Analysis) analyzeMatch(g config.Group, warn, note func(string, string, ...any)) {
if g.Match.Any {
warn("match.any", "This group matches every client, so any addresses, suffixes or tags below it are ignored, and so are all groups after it.")
}
for _, s := range g.Match.CIDRs {
p, err := netip.ParsePrefix(strings.TrimSpace(s))
if err != nil {
continue
}
if p != p.Masked() {
warn("match.cidrs", "%s has host bits set; it is treated as %s.", s, p.Masked())
}
if (p.Addr().Is4() && p.Bits() < 8) || (!p.Addr().Is4() && p.Bits() < 16) {
warn("match.cidrs", "%s is a very wide range: it covers a large share of the address space, probably more than you meant.", s)
}
}
if len(g.Match.PTRSuffixes) > 0 || len(g.Match.Tags) > 0 {
note("match", "Reverse-DNS suffixes and tags depend on the client's PTR record. The first queries from a client whose name is not yet cached are matched on address alone, so use a CIDR when the match must be reliable.")
}
for _, t := range g.Match.Tags {
if strings.Contains(t, ".") {
warn("match.tags", "Tag %q contains a dot. A tag is a single label of the client's reverse name (\"laptop\" for laptop.lan.example.net), so this never matches. Use a reverse-DNS suffix for a domain.", t)
}
}
for _, s := range g.Match.PTRSuffixes {
if strings.ContainsAny(s, "*/ ") {
warn("match.ptr_suffixes", "%q is not a plain domain suffix; wildcards and paths are not understood here, so this never matches.", s)
}
}
}
func (a *Analysis) analyzeDomains(g config.Group, warn, note func(string, string, ...any)) {
lint := func(field string, patterns []string) {
for _, raw := range patterns {
p := strings.ToLower(strings.TrimSpace(raw))
switch {
case p == "":
case strings.Contains(p, "://") || strings.Contains(p, "/"):
warn(field, "%q looks like a URL. Use only the host name, e.g. example.com.", raw)
case strings.ContainsAny(p, " \t"):
warn(field, "%q contains a space, so it never matches a name.", raw)
case strings.HasPrefix(p, "."):
warn(field, "%q starts with a dot, which is matched literally and never matches. Write *.%s to cover the name and everything under it.", raw, strings.TrimLeft(p, "."))
case p == "*" || p == "**" || p == "*.*":
if field == "deny_domains" {
warn(field, "%q blocks every name for this group.", raw)
} else {
warn(field, "%q allows every name, so the allow list restricts nothing.", raw)
}
case strings.Contains(strings.TrimPrefix(strings.TrimPrefix(p, "**."), "*."), "*"):
warn(field, "%q uses a wildcard somewhere other than the start, which is not supported and never matches. Only a leading *. or **. works.", raw)
}
}
}
lint("allow_domains", g.AllowDomains)
lint("deny_domains", g.DenyDomains)
if len(g.AllowDomains) > 0 || len(g.DenyDomains) > 0 {
note("deny_domains", "*.example.com and **.example.com mean the same thing in these lists: the name itself and everything under it. Write example.com for the exact name only.")
}
if len(g.AllowDomains) > 0 && len(g.DenyDomains) > 0 {
allow, deny := NewDomainSet(g.AllowDomains), NewDomainSet(g.DenyDomains)
for _, p := range g.AllowDomains {
name := strings.TrimPrefix(strings.TrimPrefix(strings.ToLower(strings.TrimSpace(p)), "**."), "*.")
if name != "" && deny.Match(name) && allow.Match(name) {
warn("allow_domains", "%q is also covered by the deny list, and deny wins, so it stays blocked.", p)
}
}
}
}
// analyzeOrder reports groups that shadow this one, and groups this one would
// shadow. Evaluation is first match wins, so a group whose clients are all
// claimed higher up is dead weight.
func (a *Analysis) analyzeOrder(c Context, warn func(string, string, ...any)) {
g := c.Groups[c.Index]
if g.Match.Any {
// The group is the catch-all: the warning on match.any covers it, and
// everything after it is reported below.
} else if covered, by, all := coverage(g, c.Groups[:c.Index]); all {
a.Unreachable = true
warn("match", "No client can ever land in this group: everything it selects is already claimed by %q above it, which wins.", by)
} else {
for _, item := range covered {
warn("match", "%s is already claimed by %q above this group, so those clients use that group's settings instead.", item.item, item.by)
}
}
for j := c.Index + 1; j < len(c.Groups); j++ {
later := c.Groups[j]
with := coverageAll(later, c.Groups[:j])
without := coverageAll(later, append(append([]config.Group(nil), c.Groups[:c.Index]...), c.Groups[c.Index+1:j]...))
if with && !without {
warn("match", "Saving this group makes %q unreachable: it now claims everything that group selects.", later.Name)
}
}
}
type claimed struct{ item, by string }
// coverage reports which of g's selectors a group in prior already claims,
// and whether that is all of them (so g can never match anyone).
func coverage(g config.Group, prior []config.Group) (items []claimed, first string, all bool) {
total := len(g.Match.CIDRs) + len(g.Match.PTRSuffixes) + len(g.Match.Tags)
if total == 0 {
return nil, "", false
}
for _, p := range prior {
if p.Match.Any {
return nil, p.Name, true
}
}
for _, s := range g.Match.CIDRs {
c, err := netip.ParsePrefix(strings.TrimSpace(s))
if err != nil {
continue
}
for _, p := range prior {
if by := coveringCIDR(c, p); by {
items = append(items, claimed{s, p.Name})
break
}
}
}
for _, s := range g.Match.PTRSuffixes {
sfx := normSuffix(s)
for _, p := range prior {
if coveringSuffix(sfx, p) {
items = append(items, claimed{s, p.Name})
break
}
}
}
for _, t := range g.Match.Tags {
t = strings.ToLower(strings.TrimSpace(t))
for _, p := range prior {
if containsFold(p.Match.Tags, t) {
items = append(items, claimed{"tag " + t, p.Name})
break
}
}
}
if len(items) == total {
first = items[0].by
return items, first, true
}
return items, "", false
}
func coverageAll(g config.Group, prior []config.Group) bool {
if g.Match.Any {
return false
}
_, _, all := coverage(g, prior)
return all
}
func coveringCIDR(c netip.Prefix, p config.Group) bool {
for _, s := range p.Match.CIDRs {
q, err := netip.ParsePrefix(strings.TrimSpace(s))
if err != nil {
continue
}
if q.Bits() <= c.Bits() && q.Contains(c.Masked().Addr()) {
return true
}
}
return false
}
func coveringSuffix(sfx string, p config.Group) bool {
for _, s := range p.Match.PTRSuffixes {
earlier := normSuffix(s)
// Anything under sfx is under earlier when sfx ends with earlier at a
// label boundary; the same test the matcher itself uses.
if matchPTR(sfx, earlier) {
return true
}
}
return false
}
func normSuffix(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s != "" && !strings.HasSuffix(s, ".") {
s += "."
}
return s
}
func containsFold(list []string, want string) bool {
for _, s := range list {
if strings.EqualFold(strings.TrimSpace(s), want) {
return true
}
}
return false
}
func whoSentence(g config.Group) string {
if g.Match.Any {
return "Applies to every client."
}
var parts []string
if len(g.Match.CIDRs) > 0 {
parts = append(parts, "whose address is in "+joinShort(g.Match.CIDRs))
}
if len(g.Match.PTRSuffixes) > 0 {
parts = append(parts, "whose reverse-DNS name ends in "+joinShort(g.Match.PTRSuffixes))
}
if len(g.Match.Tags) > 0 {
parts = append(parts, "tagged "+joinShort(g.Match.Tags))
}
if len(parts) == 0 {
return "Selects no clients yet."
}
return "Applies to clients " + strings.Join(parts, ", or ") + "."
}
func (c Context) orderSentence() string {
pos, n := c.Index+1, len(c.Groups)
switch {
case pos == 1 && n == 1:
return "It is the only group; clients that match nothing get the default policy (" + c.defaultPolicy() + ")."
case pos == 1:
return fmt.Sprintf("It is checked first of %d groups; a client matching it never reaches the others.", n)
default:
return fmt.Sprintf("It is checked %s of %d groups, after the %d above it; clients matching none get the default policy (%s).", ordinal(pos), n, pos-1, c.defaultPolicy())
}
}
func (c Context) defaultPolicy() string {
if c.DefaultPolicy == "" {
return "allow"
}
return c.DefaultPolicy
}
func ordinal(n int) string {
switch n {
case 2:
return "second"
case 3:
return "third"
}
return fmt.Sprintf("number %d", n)
}
// joinShort lists up to three items and counts the rest.
func joinShort(items []string) string {
clean := make([]string, 0, len(items))
for _, s := range items {
if s = strings.TrimSpace(s); s != "" {
clean = append(clean, s)
}
}
if len(clean) > 3 {
return strings.Join(clean[:3], ", ") + fmt.Sprintf(" and %d more", len(clean)-3)
}
return strings.Join(clean, ", ")
}
+172
View File
@@ -0,0 +1,172 @@
package acl
import (
"strings"
"testing"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
func named(name, policy string, m config.Match) config.Group {
return config.Group{Name: name, Policy: policy, Match: m}
}
func analyzeAt(idx int, defRate int, groups ...config.Group) Analysis {
return Analyze(Context{Groups: groups, Index: idx, DefaultRateLimit: defRate, DefaultPolicy: "allow"})
}
func has(fs []Finding, field, contains string) bool {
for _, f := range fs {
if f.Field == field && strings.Contains(f.Message, contains) {
return true
}
}
return false
}
func TestAnalyzeAllowListIsAllowlistMode(t *testing.T) {
g := named("kids", "allow", config.Match{CIDRs: []string{"10.0.5.0/24"}})
g.AllowDomains = []string{"*.school.example"}
a := analyzeAt(0, 0, g)
if !has(a.Notes, "allow_domains", "allowlist-only") {
t.Errorf("notes = %+v, want the allowlist-only explanation", a.Notes)
}
if !strings.Contains(strings.Join(a.Summary, " "), "ONLY") {
t.Errorf("summary = %v, want it to say only the listed names resolve", a.Summary)
}
}
func TestAnalyzeBlockOnlyIsTheSameAsAllow(t *testing.T) {
a := analyzeAt(0, 0, named("g", "block_only", config.Match{CIDRs: []string{"10.0.0.0/24"}}))
if !has(a.Warnings, "policy", "exactly like allow") {
t.Errorf("warnings = %+v, want the block_only warning", a.Warnings)
}
}
func TestAnalyzeRateLimitZeroExemptsFromTheDefault(t *testing.T) {
g := named("g", "allow", config.Match{CIDRs: []string{"10.0.0.0/24"}})
if a := analyzeAt(0, 50, g); !has(a.Warnings, "rate_limit", "exempt") {
t.Errorf("warnings = %+v, want the exemption warning", a.Warnings)
}
if a := analyzeAt(0, 0, g); len(a.Warnings) != 0 {
t.Errorf("no default limit, yet warnings = %+v", a.Warnings)
}
g.RateLimit = 20
if a := analyzeAt(0, 50, g); has(a.Warnings, "rate_limit", "exempt") {
t.Errorf("an explicit limit must not warn about exemption: %+v", a.Warnings)
}
}
func TestAnalyzeDenyIgnoresTheRest(t *testing.T) {
g := named("g", "deny", config.Match{CIDRs: []string{"10.0.0.0/24"}})
g.RateLimit = 5
g.SkipBlocklist = true
a := analyzeAt(0, 50, g)
if !has(a.Notes, "rate_limit", "Ignored") || !has(a.Notes, "skip_blocklist", "Ignored") {
t.Errorf("notes = %+v, want ignored-setting notes", a.Notes)
}
if has(a.Warnings, "rate_limit", "exempt") {
t.Errorf("a denied group has no rate limit to be exempt from: %+v", a.Warnings)
}
}
func TestAnalyzeShadowing(t *testing.T) {
wide := named("lan", "allow", config.Match{CIDRs: []string{"10.0.0.0/8"}})
narrow := named("cam", "deny", config.Match{CIDRs: []string{"10.1.2.0/24"}})
a := analyzeAt(1, 0, wide, narrow)
if !a.Unreachable || !has(a.Warnings, "match", `"lan"`) {
t.Errorf("narrow range after a wider one: unreachable=%v warnings=%+v", a.Unreachable, a.Warnings)
}
// The other order is the useful one and must stay quiet.
if a := analyzeAt(0, 0, narrow, wide); a.Unreachable || len(a.Warnings) > 1 {
t.Errorf("specific-before-general flagged: unreachable=%v warnings=%+v", a.Unreachable, a.Warnings)
}
// Saving the wide group above the narrow one buries the narrow one.
a = analyzeAt(0, 0, wide, narrow)
if !has(a.Warnings, "match", `makes "cam" unreachable`) {
t.Errorf("warnings = %+v, want the newly-unreachable warning", a.Warnings)
}
}
func TestAnalyzePartialOverlapNamesTheClaimedRange(t *testing.T) {
first := named("a", "allow", config.Match{CIDRs: []string{"10.0.0.0/24"}})
second := named("b", "allow", config.Match{CIDRs: []string{"10.0.0.128/25", "192.168.0.0/24"}})
a := analyzeAt(1, 0, first, second)
if a.Unreachable {
t.Fatal("one range is still free; the group is reachable")
}
if !has(a.Warnings, "match", "10.0.0.128/25 is already claimed by \"a\"") {
t.Errorf("warnings = %+v", a.Warnings)
}
}
func TestAnalyzeReverseNameAndTagShadowing(t *testing.T) {
first := named("a", "allow", config.Match{PTRSuffixes: []string{"lan.example."}, Tags: []string{"laptop"}})
second := named("b", "allow", config.Match{PTRSuffixes: []string{".lab.lan.example."}, Tags: []string{"Laptop"}})
if a := analyzeAt(1, 0, first, second); !a.Unreachable {
t.Errorf("a suffix under an earlier suffix plus the same tag is fully claimed: %+v", a)
}
}
func TestAnalyzeGroupAfterACatchAllIsUnreachable(t *testing.T) {
all := named("all", "allow", config.Match{Any: true, CIDRs: []string{"10.0.0.0/8"}})
g := named("g", "allow", config.Match{CIDRs: []string{"192.168.0.0/24"}})
a := analyzeAt(1, 0, all, g)
if !a.Unreachable {
t.Error("nothing after a match-any group can be reached")
}
if b := analyzeAt(0, 0, all, g); !has(b.Warnings, "match.any", "every client") {
t.Errorf("warnings = %+v, want the match-any warning", b.Warnings)
}
}
func TestAnalyzeCIDRHygiene(t *testing.T) {
a := analyzeAt(0, 0, named("g", "allow", config.Match{CIDRs: []string{"192.168.1.5/24", "0.0.0.0/0"}}))
if !has(a.Warnings, "match.cidrs", "host bits") || !has(a.Warnings, "match.cidrs", "very wide") {
t.Errorf("warnings = %+v", a.Warnings)
}
}
func TestAnalyzeDomainPatternMistakes(t *testing.T) {
g := named("g", "allow", config.Match{CIDRs: []string{"10.0.0.0/24"}})
g.AllowDomains = []string{"https://example.com/a", ".example.org", "ads.*.com", "*.fine.example", "*"}
g.DenyDomains = []string{"*.fine.example"}
a := analyzeAt(0, 0, g)
for _, want := range []string{"looks like a URL", "starts with a dot", "not supported", "allows every name", "deny wins"} {
if !has(a.Warnings, "allow_domains", want) {
t.Errorf("missing allow_domains warning containing %q in %+v", want, a.Warnings)
}
}
}
func TestAnalyzeTagWithADotNeverMatches(t *testing.T) {
a := analyzeAt(0, 0, named("g", "allow", config.Match{Tags: []string{"lan.example"}}))
if !has(a.Warnings, "match.tags", "single label") {
t.Errorf("warnings = %+v", a.Warnings)
}
}
func TestAnalyzeUnknownUpstreams(t *testing.T) {
g := named("g", "allow", config.Match{CIDRs: []string{"10.0.0.0/24"}})
g.Upstreams = []string{"9.9.9.9:53"}
a := Analyze(Context{Groups: []config.Group{g}, Upstreams: []string{"1.1.1.1:53"}})
if !has(a.Warnings, "upstreams", "uses ALL upstreams") {
t.Errorf("warnings = %+v", a.Warnings)
}
g.Upstreams = []string{"1.1.1.1:53"}
if a := Analyze(Context{Groups: []config.Group{g}, Upstreams: []string{"1.1.1.1:53"}}); has(a.Warnings, "upstreams", "") {
t.Errorf("a configured upstream warned: %+v", a.Warnings)
}
if a := Analyze(Context{Groups: []config.Group{g}}); has(a.Warnings, "upstreams", "") {
t.Errorf("unknown upstream list must skip the check: %+v", a.Warnings)
}
}
func TestAnalyzeToleratesABadIndex(t *testing.T) {
if a := Analyze(Context{Index: 3}); len(a.Summary) != 0 {
t.Errorf("summary = %v", a.Summary)
}
}