diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go index 3aa3aea..63e9e42 100644 --- a/pkg/cache/cache_test.go +++ b/pkg/cache/cache_test.go @@ -3,6 +3,7 @@ package cache import ( + "errors" "testing" logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger" @@ -160,3 +161,186 @@ func Test_nextReader(t *testing.T) { }) } } + +// countingCache is an isolated cacheInterface recording how many reads a lookup costs, +// so a CIDR lookup can be checked for both its result and its price. +type countingCache struct { + values map[string]string + reads int +} + +func newCountingCache() *countingCache { + return &countingCache{values: map[string]string{}} +} + +func (c *countingCache) get(key string) (string, error) { + c.reads++ + if value, found := c.values[key]; found && value != "" { + return value, nil + } + return "", errors.New(CacheMiss) +} + +func (c *countingCache) set(key, value string, _ int64) { + c.values[key] = value +} + +func (c *countingCache) delete(key string) { + delete(c.values, key) +} + +func newCIDRClient(decisions map[string]string) (*Client, *countingCache) { + counting := newCountingCache() + client := &Client{cache: counting, log: logger.New("INFO", "")} + for cidr, value := range decisions { + client.SetCIDR(cidr, value, 60) + } + return client, counting +} + +func Test_GetCIDR(t *testing.T) { + decisions := map[string]string{ + "10.0.0.0/24": BannedValue, + "192.168.1.42/24": CaptchaValue, // not a network address, host bits are dropped + "2001:db8::/32": BannedValue, + } + tests := []struct { + name string + clientIP string + want string + wantErr bool + }{ + {name: "IP inside a banned range", clientIP: "10.0.0.7", want: BannedValue}, + {name: "network address itself", clientIP: "10.0.0.0", want: BannedValue}, + {name: "broadcast address of the range", clientIP: "10.0.0.255", want: BannedValue}, + {name: "IP just outside the range", clientIP: "10.0.1.0", wantErr: true}, + {name: "IP inside a captcha range", clientIP: "192.168.1.7", want: CaptchaValue}, + {name: "IP inside an IPv6 range", clientIP: "2001:db8::dead:beef", want: BannedValue}, + {name: "IP outside the IPv6 range", clientIP: "2001:db9::1", wantErr: true}, + {name: "IPv4 mapped client against an IPv4 range", clientIP: "::ffff:10.0.0.7", want: BannedValue}, + {name: "unknown IP", clientIP: "8.8.8.8", wantErr: true}, + {name: "invalid IP", clientIP: "not-an-ip", wantErr: true}, + {name: "empty IP", clientIP: "", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, _ := newCIDRClient(decisions) + got, err := client.GetCIDR(tt.clientIP) + if (err != nil) != tt.wantErr { + t.Fatalf("GetCIDR(%q) error = %v, wantErr %v", tt.clientIP, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("GetCIDR(%q) = %q, want %q", tt.clientIP, got, tt.want) + } + }) + } +} + +// Test_GetCIDR_MostSpecific pins precedence: the narrowest range wins, so a captcha on +// a /24 is not overruled by a ban on its /8. +func Test_GetCIDR_MostSpecific(t *testing.T) { + client, _ := newCIDRClient(map[string]string{ + "10.0.0.0/8": BannedValue, + "10.1.0.0/16": CaptchaValue, + "10.1.2.0/24": BannedValue, + "2001:db8::/32": BannedValue, + "2001:db8::/48": CaptchaValue, + }) + tests := []struct { + clientIP string + want string + }{ + {clientIP: "10.1.2.3", want: BannedValue}, + {clientIP: "10.1.3.3", want: CaptchaValue}, + {clientIP: "10.2.3.4", want: BannedValue}, + {clientIP: "2001:db8::1", want: CaptchaValue}, + {clientIP: "2001:db8:1::1", want: BannedValue}, + } + for _, tt := range tests { + t.Run(tt.clientIP, func(t *testing.T) { + got, err := client.GetCIDR(tt.clientIP) + if err != nil { + t.Fatalf("GetCIDR(%q) unexpected error %v", tt.clientIP, err) + } + if got != tt.want { + t.Errorf("GetCIDR(%q) = %q, want %q", tt.clientIP, got, tt.want) + } + }) + } +} + +func Test_DeleteCIDR(t *testing.T) { + client, _ := newCIDRClient(map[string]string{ + "10.0.0.0/8": BannedValue, + "10.1.2.0/24": CaptchaValue, + }) + client.DeleteCIDR("10.1.2.0/24") + // The wider decision is untouched and takes over. + if got, err := client.GetCIDR("10.1.2.3"); err != nil || got != BannedValue { + t.Errorf("after deleting the /24, GetCIDR = %q %v, want %q", got, err, BannedValue) + } + client.DeleteCIDR("10.0.0.0/8") + if _, err := client.GetCIDR("10.1.2.3"); err == nil { + t.Error("GetCIDR should miss once every decision is deleted") + } +} + +func Test_SetCIDR_InvalidIsNotStored(t *testing.T) { + for _, cidr := range []string{"", "garbage", "10.0.0.1", "10.0.0.0/33", "10.0.0.0/-1"} { + t.Run(cidr, func(t *testing.T) { + _, counting := newCIDRClient(map[string]string{cidr: BannedValue}) + if len(counting.values) != 0 { + t.Errorf("SetCIDR(%q) stored %v, want nothing", cidr, counting.values) + } + }) + } +} + +// Test_GetCIDR_Reads guards the cost of the lookup: it must probe only the prefix +// lengths that have a decision, not every possible one. +func Test_GetCIDR_Reads(t *testing.T) { + tests := []struct { + name string + decisions map[string]string + clientIP string + wantReads int + }{ + {name: "no decision at all, IPv4", decisions: nil, clientIP: "10.0.0.1", wantReads: 1}, + {name: "no decision at all, IPv6", decisions: nil, clientIP: "2001:db8::1", wantReads: 1}, + { + name: "one prefix length, hit", + decisions: map[string]string{"10.0.0.0/24": BannedValue}, + clientIP: "10.0.0.1", + wantReads: 2, + }, + { + name: "one prefix length, miss", + decisions: map[string]string{"10.0.0.0/24": BannedValue}, + clientIP: "11.0.0.1", + wantReads: 2, + }, + { + name: "three prefix lengths, miss probes each once", + decisions: map[string]string{"10.0.0.0/8": BannedValue, "10.1.0.0/16": BannedValue, "10.1.2.0/24": BannedValue}, + clientIP: "11.0.0.1", + wantReads: 4, + }, + { + name: "IPv6 client does not probe every length", + decisions: map[string]string{"2001:db8::/32": BannedValue}, + clientIP: "2001:dead::1", + wantReads: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, counting := newCIDRClient(tt.decisions) + counting.reads = 0 + // Only the number of reads matters here, the result is covered above. + _, _ = client.GetCIDR(tt.clientIP) + if counting.reads != tt.wantReads { + t.Errorf("GetCIDR(%q) did %d cache reads, want %d", tt.clientIP, counting.reads, tt.wantReads) + } + }) + } +}