package domains import ( "errors" "path/filepath" "strings" "testing" "github.com/colinmarc/cdb" ) // All expectations below come from the real production-shaped CDB shipped in // test_files/, dumped with `cdb -d -m` (tinycdb). 93 549 records, every value // "|O/i,O/o,D/i,D/o,A/i,A/o[,@/...]". const testCDB = "../../test_files/rspamd_domains_db.cdb" func openTestDB(t *testing.T) *DB { t.Helper() db, err := Open(testCDB) if err != nil { t.Fatalf("Open(%s): %v", testCDB, err) } t.Cleanup(func() { db.Close() }) return db } func mustLookup(t *testing.T, db *DB, domain string) *Domain { t.Helper() d, err := db.Lookup(domain) if err != nil { t.Fatalf("Lookup(%q): %v", domain, err) } return d } func TestOpen_MissingFile(t *testing.T) { if _, err := Open("/nonexistent/rspamd_domains_db"); err == nil { t.Fatal("expected error for missing file") } } func TestDB_PathAndCount(t *testing.T) { db := openTestDB(t) if db.Path() != testCDB { t.Errorf("Path() = %q", db.Path()) } if got := db.Count(); got != 93549 { t.Errorf("Count() = %d, want 93549", got) } } func TestLookup_RealRecord(t *testing.T) { db := openTestDB(t) d := mustLookup(t, db, "yagolivas.com") if d.Name != "yagolivas.com" || d.OrgID != 1049 { t.Fatalf("got %+v", d) } want := Limits{"O/i": 200, "O/o": 20, "D/i": 200, "D/o": 20, "A/i": 200, "A/o": 20} if len(d.Limits) != len(want) { t.Fatalf("limits = %v, want %v", d.Limits, want) } for k, v := range want { if d.Limits[k] != v { t.Errorf("limits[%s] = %d, want %d", k, d.Limits[k], v) } } } func TestLookup_NotFound(t *testing.T) { db := openTestDB(t) for _, domain := range []string{"gmail.com", "example.com", "example.org", ""} { _, err := db.Lookup(domain) if !errors.Is(err, ErrNotFound) { t.Errorf("Lookup(%q) err = %v, want ErrNotFound", domain, err) } } } func TestLookup_NormalizesCase(t *testing.T) { db := openTestDB(t) d := mustLookup(t, db, " YagoLivas.COM ") if d.OrgID != 1049 || d.Name != "yagolivas.com" { t.Errorf("got %+v", d) } } // boxmailhere.com carries a 193 KB value with 9 798 per-address overrides. func TestLookup_HugeRecord(t *testing.T) { db := openTestDB(t) d := mustLookup(t, db, "boxmailhere.com") if d.OrgID != 87716 { t.Errorf("OrgID = %d, want 87716", d.OrgID) } if len(d.Limits) != 9804 { t.Errorf("len(Limits) = %d, want 9804 (6 base + 9798 overrides)", len(d.Limits)) } checkLimit(t, d.Limits, AddressLevel, FlowIn, "gangstaelmo7", 500, true) checkLimit(t, d.Limits, AddressLevel, FlowOut, "gangstaelmo7", 30, true) checkLimit(t, d.Limits, AddressLevel, FlowIn, "nobody-with-override", 250000, true) checkLimit(t, d.Limits, OrganizationLevel, FlowOut, "", 4000, true) } func checkLimit(t *testing.T, l Limits, level, flow, local string, want int64, wantOK bool) { t.Helper() got, ok := l.Limit(level, flow, local) if got != want || ok != wantOK { t.Errorf("Limit(%s, %s, %q) = (%d, %v), want (%d, %v)", level, flow, local, got, ok, want, wantOK) } } // crisdanshopping.com.br: A/i=500,A/o=100 plus contato@ and admin@ overrides. func TestLimit_AddressOverride(t *testing.T) { db := openTestDB(t) d := mustLookup(t, db, "crisdanshopping.com.br") if d.OrgID != 56 { t.Fatalf("OrgID = %d", d.OrgID) } l := d.Limits checkLimit(t, l, AddressLevel, FlowIn, "contato", 300, true) checkLimit(t, l, AddressLevel, FlowOut, "contato", 150, true) checkLimit(t, l, AddressLevel, FlowIn, "admin", 300, true) checkLimit(t, l, AddressLevel, FlowOut, "admin", 150, true) checkLimit(t, l, AddressLevel, FlowIn, "someone", 500, true) // falls back to A/i checkLimit(t, l, AddressLevel, FlowOut, "someone", 100, true) checkLimit(t, l, DomainLevel, FlowIn, "", 1000, true) checkLimit(t, l, DomainLevel, FlowOut, "", 120, true) checkLimit(t, l, OrganizationLevel, FlowIn, "", 1000, true) checkLimit(t, l, OrganizationLevel, FlowOut, "", 120, true) // local part is irrelevant for D and O checkLimit(t, l, DomainLevel, FlowIn, "contato", 1000, true) } // meu-servidor.com has overrides whose local parts contain dots. func TestLimit_DottedLocalPart(t *testing.T) { db := openTestDB(t) d := mustLookup(t, db, "meu-servidor.com") if d.OrgID != 726 { t.Fatalf("OrgID = %d", d.OrgID) } checkLimit(t, d.Limits, AddressLevel, FlowIn, "sexprime.xxx", 100, true) checkLimit(t, d.Limits, AddressLevel, FlowIn, "cf-tgamesandroid.com", 1000, true) checkLimit(t, d.Limits, AddressLevel, FlowOut, "cf-tgamesandroid.com", 100, true) checkLimit(t, d.Limits, AddressLevel, FlowIn, "alerta", 1000, true) checkLimit(t, d.Limits, AddressLevel, FlowOut, "alerta", 100, true) checkLimit(t, d.Limits, AddressLevel, FlowIn, "nobody", 500, true) } func TestLimit_MigaduSelf(t *testing.T) { db := openTestDB(t) d := mustLookup(t, db, "migadu.com") if d.OrgID != 3743 { t.Fatalf("OrgID = %d", d.OrgID) } checkLimit(t, d.Limits, AddressLevel, FlowIn, "admin", 10000, true) checkLimit(t, d.Limits, AddressLevel, FlowIn, "someone", 100000, true) } func TestLimit_Unlimited(t *testing.T) { l := ParseLimits("O/i=5") checkLimit(t, l, OrganizationLevel, FlowIn, "", 5, true) checkLimit(t, l, OrganizationLevel, FlowOut, "", 0, false) checkLimit(t, l, DomainLevel, FlowIn, "", 0, false) checkLimit(t, l, AddressLevel, FlowIn, "x", 0, false) checkLimit(t, Limits(nil), AddressLevel, FlowIn, "x", 0, false) checkLimit(t, l, "Z", FlowIn, "", 0, false) // unknown level } func TestParseLimits(t *testing.T) { l := ParseLimits(" O/i = 5000,O/o=1000, broken ,x=notanumber,=3,A/o=,,alice@/o=7") want := Limits{"O/i": 5000, "O/o": 1000, "alice@/o": 7} if len(l) != len(want) { t.Fatalf("got %v, want %v", l, want) } for k, v := range want { if l[k] != v { t.Errorf("%s = %d, want %d", k, l[k], v) } } if got := ParseLimits(""); len(got) != 0 { t.Errorf("ParseLimits(\"\") = %v, want empty", got) } } func TestParseRecord(t *testing.T) { tests := []struct { name, value string wantOrg int64 wantLimits int wantErr bool }{ {"normal", "1049|O/i=200,O/o=20", 1049, 2, false}, {"spaces", " 7 | A/i=3 ", 7, 1, false}, {"no pipe", "42", 42, 0, false}, // must not panic (the Lua plugin did) {"empty limits", "42|", 42, 0, false}, {"non-numeric org", "abc|O/i=1", 0, 0, true}, {"empty", "", 0, 0, true}, {"negative org", "-1|O/i=1", 0, 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { d, err := ParseRecord("x.com", tt.value) if tt.wantErr { if err == nil { t.Fatalf("expected error, got %+v", d) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if d.Name != "x.com" || d.OrgID != tt.wantOrg || len(d.Limits) != tt.wantLimits { t.Errorf("got %+v, want org=%d limits=%d", d, tt.wantOrg, tt.wantLimits) } }) } } // String renders the Lua-like limits_map used by the transaction log, sorted. func TestLimits_String(t *testing.T) { l := ParseLimits("O/o=20,A/i=200,A/o=20,O/i=200,D/i=200,D/o=20,info@/o=5") want := "{A/i=200, A/o=20, D/i=200, D/o=20, O/i=200, O/o=20, info@/o=5}" if got := l.String(); got != want { t.Errorf("String() = %q, want %q", got, want) } if got := (Limits{}).String(); got != "{}" { t.Errorf("empty String() = %q", got) } } func TestDB_Ping(t *testing.T) { db, err := Open(testCDB) if err != nil { t.Fatal(err) } if err := db.Ping(); err != nil { t.Errorf("Ping() = %v", err) } db.Close() if err := db.Ping(); err == nil { t.Error("Ping() after Close() should fail") } } // A CDB written with colinmarc/cdb's Writer (as tests and tooling do) must be // readable through the same path as the agent-built file. func TestLookup_WriterBuiltCDB(t *testing.T) { path := filepath.Join(t.TempDir(), "small.cdb") w, err := cdb.Create(path) if err != nil { t.Fatal(err) } if err := w.Put([]byte("newdomain.test"), []byte("5|A/i=1,A/o=1,D/i=2,D/o=2,O/i=3,O/o=3")); err != nil { t.Fatal(err) } if err := w.Close(); err != nil { t.Fatal(err) } db, err := Open(path) if err != nil { t.Fatal(err) } defer db.Close() if db.Count() != 1 { t.Errorf("Count() = %d, want 1", db.Count()) } d, err := db.Lookup("newdomain.test") if err != nil { t.Fatalf("Lookup: %v", err) } if d.OrgID != 5 { t.Errorf("OrgID = %d", d.OrgID) } if _, err := db.Lookup("other.test"); !errors.Is(err, ErrNotFound) { t.Errorf("Lookup(other) err = %v, want ErrNotFound", err) } } // Parsed records are cached per loaded table: a second Lookup of the same // domain returns the very same *Domain (no re-parse), and is safe because // callers never mutate it. func TestLookup_CachesParsedRecord(t *testing.T) { db := openTestDB(t) first := mustLookup(t, db, "boxmailhere.com") second := mustLookup(t, db, "BoxMailHere.com ") if first != second { t.Error("second lookup re-parsed the record instead of serving the cached *Domain") } if db.Cached() != 1 { t.Errorf("Cached() = %d, want 1", db.Cached()) } if _, err := db.Lookup("nobody.invalid"); !errors.Is(err, ErrNotFound) { t.Fatal(err) } if db.Cached() != 1 { t.Errorf("not-found lookups must not be cached: Cached() = %d", db.Cached()) } } // The cache is bounded: beyond max entries lookups still work but parse // without caching; 0 disables the cache entirely. func TestLookup_CacheBounded(t *testing.T) { db, err := OpenWithCache(testCDB, 1) if err != nil { t.Fatal(err) } defer db.Close() a1 := mustLookup(t, db, "yagolivas.com") a2 := mustLookup(t, db, "yagolivas.com") b1 := mustLookup(t, db, "boxmailhere.com") b2 := mustLookup(t, db, "boxmailhere.com") if a1 != a2 { t.Error("first domain should be cached") } if b1 == b2 { t.Error("second domain exceeds max_cached_domains=1 and must not be cached") } if b1.OrgID != 87716 || db.Cached() != 1 { t.Errorf("uncached lookup still parses: org=%d cached=%d", b1.OrgID, db.Cached()) } off, err := OpenWithCache(testCDB, 0) if err != nil { t.Fatal(err) } defer off.Close() if mustLookup(t, off, "yagolivas.com") == mustLookup(t, off, "yagolivas.com") { t.Error("cache disabled (0) must parse every time") } } func benchLookup(b *testing.B, dom string) { db, err := Open(testCDB) if err != nil { b.Fatal(err) } defer db.Close() if _, err := db.Lookup(dom); err != nil { // warm the cache b.Fatal(err) } b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { if _, err := db.Lookup(dom); err != nil { b.Fatal(err) } } } // With the cache warm, the 193 KB boxmailhere.com record costs the same as a // small one (review finding #5: 0.9 ms and 1.4 MB per lookup before). func BenchmarkLookupSmall(b *testing.B) { benchLookup(b, "yagolivas.com") } func BenchmarkLookupHuge(b *testing.B) { benchLookup(b, "boxmailhere.com") } // A row with a non-numeric org id is reported as malformed, which callers // treat like "not hosted" (review #8) — it must never fail the request. func TestLookup_MalformedRow(t *testing.T) { db, err := Open("../../review/_proof_tests/b/bad.cdb") if err != nil { t.Fatal(err) } defer db.Close() _, err = db.Lookup("bad.com") if !errors.Is(err, ErrMalformed) { t.Fatalf("Lookup(bad.com) err = %v, want ErrMalformed", err) } if !strings.Contains(err.Error(), `invalid org id "x12"`) { t.Errorf("error should name the problem: %v", err) } if d, err := db.Lookup("good.com"); err != nil || d.OrgID != 7 { t.Errorf("good.com = %+v, %v", d, err) } if db.Cached() != 1 { t.Errorf("malformed rows must not be cached: Cached() = %d", db.Cached()) } } func TestMailbox(t *testing.T) { for in, want := range map[string]string{ "b+1@example.com": "b@example.com", "b+tag+more@example.com": "b@example.com", "b@example.com": "b@example.com", "+lead@example.com": "+lead@example.com", // empty local part would be nonsense: keep as is "nodomain": "nodomain", } { if got := Mailbox(in); got != want { t.Errorf("Mailbox(%q) = %q, want %q", in, got, want) } } }