Files
ntfy/server/visitor_test.go
T

233 lines
9.5 KiB
Go

package server
import (
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
)
// newBanTestVisitor creates a visitor wired for ban-feed testing, with the given ban file,
// weighted-bucket threshold, and weights, plus a 1-minute window (so the emit throttle only
// fires once per test).
func newBanTestVisitor(t *testing.T, banFile string, threshold int, weights map[string]int) *visitor {
conf := NewConfig()
conf.BanFile = banFile
conf.BanWindow = time.Minute
conf.BanThreshold = threshold
conf.BanWeights = weights
return newVisitor(conf, nil, nil, netip.MustParseAddr("1.2.3.4"), nil)
}
func readBanLines(t *testing.T, path string) []string {
data, err := os.ReadFile(path)
require.NoError(t, err)
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
}
func TestVisitor_RecordStatus_Weight2BansAtHalfThreshold(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// Threshold 10, code weight 2 -> the budget covers exactly 5 hits, so the 6th breaches.
v := newBanTestVisitor(t, banFile, 10, map[string]int{"*": 2})
for i := 0; i < 5; i++ {
v.recordStatus(v.ip, 400, 40001)
}
require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over
v.recordStatus(v.ip, 400, 40001) // 6th hit cannot be covered -> breach
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) // <ip> <prefix> <http> <ntfy-code>
}
func TestVisitor_RecordStatus_Weight10BansFast(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// Threshold 10, code weight 10 -> a single hit drains the whole budget, so the 2nd breaches.
v := newBanTestVisitor(t, banFile, 10, map[string]int{"42909": 10, "*": 1})
v.recordStatus(v.ip, 429, 42909)
require.NoFileExists(t, banFile)
v.recordStatus(v.ip, 429, 42909) // 2nd hit cannot be covered -> breach
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42909"))
}
func TestVisitor_RecordStatus_Weight0NeverBans(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// The legit-quota code is exempt (weight 0), so no number of hits ever bans.
v := newBanTestVisitor(t, banFile, 10, map[string]int{"42908": 0, "*": 1})
for i := 0; i < 100; i++ {
v.recordStatus(v.ip, 429, 42908)
}
require.NoFileExists(t, banFile)
}
func TestVisitor_RecordStatus_SingleBucketNoRelaxation(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// One shared bucket: different codes draw down the SAME budget, so mixing them creates no extra
// headroom (unlike per-code buckets, which would relax the effective limit for a mixed offender).
v := newBanTestVisitor(t, banFile, 10, map[string]int{"403*": 2, "*": 1})
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 8 left
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 6 left
v.recordStatus(v.ip, 403, 40301) // weight 2 -> 4 left
require.NoFileExists(t, banFile)
for i := 0; i < 4; i++ {
v.recordStatus(v.ip, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left
}
require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over
v.recordStatus(v.ip, 400, 40001) // one more cannot be covered -> breach
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
}
func TestVisitor_RecordStatus_ExactLineFormat(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1})
before := time.Now().UTC().Truncate(time.Second)
for i := 0; i < 3; i++ {
v.recordStatus(v.ip, 429, 42901)
}
after := time.Now().UTC()
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
parts := strings.Split(lines[0], " ")
require.Len(t, parts, 5) // "<timestamp> <ip> <prefix> <http-code> <ntfy-code>"
ts, err := time.Parse(time.RFC3339, parts[0])
require.NoError(t, err)
require.Equal(t, time.UTC, ts.Location())
require.False(t, ts.Before(before))
require.False(t, ts.After(after.Add(time.Second)))
require.Equal(t, "1.2.3.4", parts[1]) // full IP
require.Equal(t, "1.2.3.4/32", parts[2]) // masked to the default IPv4 prefix (/32)
require.Equal(t, "429", parts[3]) // HTTP status
require.Equal(t, "42901", parts[4]) // ntfy code
}
func TestVisitor_RecordStatus_IPv6MaskedToPrefix(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
conf := NewConfig()
conf.BanFile = banFile
conf.BanWindow = time.Minute
conf.BanThreshold = 1
conf.BanWeights = map[string]int{"*": 1}
v := newVisitor(conf, nil, nil, netip.MustParseAddr("2001:db8::abcd"), nil)
for i := 0; i < 3; i++ {
v.recordStatus(v.ip, 429, 42901)
}
parts := strings.Split(readBanLines(t, banFile)[0], " ")
require.Len(t, parts, 5)
require.Equal(t, "2001:db8::abcd", parts[1]) // full IPv6 address
require.Equal(t, "2001:db8::/64", parts[2]) // masked to the default IPv6 prefix (/64)
}
func TestVisitor_RecordStatus_TierUserAlsoBanned(t *testing.T) {
// A tier'd user who keeps hammering the request limiter (42901) is banned by IP like anyone
// else -- a persistent 429 stream is abusive regardless of the account behind it. The legit
// case (hitting a paid plan/quota limit) is spared by the per-code weights, not by a tier skip:
// codes like 42908 are weight 0. So tier does not exempt a visitor from the ban feed.
banFile := filepath.Join(t.TempDir(), "ban.log")
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1})
v.user = &user.User{ID: "u_test", Name: "test", Tier: &user.Tier{ID: "ti_test"}}
for i := 0; i < 10; i++ {
v.recordStatus(v.ip, 429, 42901)
}
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42901"))
}
func TestVisitor_RecordStatus_BansOffendingIPNotVisitorIP(t *testing.T) {
// For an account-keyed (tier'd) visitor, one visitor object serves many source IPs and its
// stored v.ip is whichever IP created it first -- stale. The ban feed must write the IP of the
// request that actually breached, passed in per-call, not the visitor's stored IP.
banFile := filepath.Join(t.TempDir(), "ban.log")
v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1}) // v.ip == 1.2.3.4
offender := netip.MustParseAddr("5.6.7.8")
for i := 0; i < 3; i++ {
v.recordStatus(offender, 429, 42901)
}
parts := strings.Split(readBanLines(t, banFile)[0], " ")
require.Equal(t, "5.6.7.8", parts[1]) // the offending request IP, not v.ip (1.2.3.4)
require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix, not the visitor's stored-IP prefix
}
func TestVisitor_RecordStatus_DisabledWhenNoBanFile(t *testing.T) {
v := newBanTestVisitor(t, "", 10, map[string]int{"*": 1})
for i := 0; i < 5; i++ {
v.recordStatus(v.ip, 403, 40301) // Feature disabled: must be a no-op, must not panic
}
require.Nil(t, v.banEmit) // No throttle limiter is created when the feature is off
}
func TestVisitor_RecordStatus_Ignores2xx3xx(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
v := newBanTestVisitor(t, banFile, 3, map[string]int{"*": 1})
// Success and redirects must never count toward a ban, even over the threshold -- otherwise a
// legit high-volume publisher (lots of 200s) would get banned.
for i := 0; i < 20; i++ {
v.recordStatus(v.ip, 200, 20000)
v.recordStatus(v.ip, 302, 30000)
}
require.NoFileExists(t, banFile)
// A 4xx over the same budget still gets written.
for i := 0; i < 5; i++ {
v.recordStatus(v.ip, 400, 40001)
}
lines := readBanLines(t, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001"))
}
func TestParseBanWeights(t *testing.T) {
// Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*".
weights, err := ParseBanWeights([]string{"42909:10", "403:2", "42908:0", "*:1"})
require.NoError(t, err)
require.Equal(t, map[string]int{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights)
// A bare 3-digit HTTP status normalizes to its family.
weights, err = ParseBanWeights([]string{"429:5"})
require.NoError(t, err)
require.Equal(t, map[string]int{"429*": 5}, weights)
// An explicit family key stays as-is.
weights, err = ParseBanWeights([]string{"429*:5"})
require.NoError(t, err)
require.Equal(t, map[string]int{"429*": 5}, weights)
// Weight 0 is valid and means exempt.
weights, err = ParseBanWeights([]string{"42908:0"})
require.NoError(t, err)
require.Equal(t, map[string]int{"42908": 0}, weights)
_, err = ParseBanWeights([]string{"401"}) // Missing weight
require.Error(t, err)
_, err = ParseBanWeights([]string{"401:-1"}) // Negative weight
require.Error(t, err)
_, err = ParseBanWeights([]string{"401:abc"}) // Non-integer weight
require.Error(t, err)
_, err = ParseBanWeights([]string{"abc:10"}) // Non-numeric key
require.Error(t, err)
_, err = ParseBanWeights([]string{"4*3:10"}) // Star not at the end
require.Error(t, err)
}
func TestConfig_WeightFor(t *testing.T) {
conf := NewConfig()
weights, err := ParseBanWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"})
require.NoError(t, err)
conf.BanWeights = weights
// Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*".
require.Equal(t, 0, conf.weightFor(42908))
require.Equal(t, 10, conf.weightFor(42909))
require.Equal(t, 1, conf.weightFor(42901))
require.Equal(t, 2, conf.weightFor(40311))
require.Equal(t, 1, conf.weightFor(40011))
require.Equal(t, 1, conf.weightFor(50312))
// A code that matches nothing (no "*" here) returns 0.
require.Equal(t, 0, conf.weightFor(30012))
}