feat: enrich /lookup with university domain list check
Add a second detection path alongside ASN lookup: a self-maintained
list of university domains (uni_domains.txt) loaded at startup.
- New /lookup params: email= (extracts domain from address), domain= unchanged
- Suffix matching: insti.uni-stuttgart.de matches list entry uni-stuttgart.de
without false-positives (evil-uni-stuttgart.de does not match)
- New response fields: asn_match, domain_match, matched_domain (omitempty)
- nren remains true if either asn_match OR domain_match is true (backwards compat)
- /healthz now returns JSON body: {"asn_count":N,"domain_count":N}
- asn-updater: new update_uni_domains() merges hs-kompass.de TSV + Hipo JSON
(configurable via UNI_DOMAIN_COUNTRIES / HS_KOMPASS_URL env vars)
- 7 new tests; all existing tests pass unchanged
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
162
main_test.go
162
main_test.go
@@ -44,3 +44,165 @@ func TestLookupServiceNotReady(t *testing.T) {
|
||||
t.Fatalf("expected 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesUniDomain(t *testing.T) {
|
||||
set := map[string]struct{}{
|
||||
"uni-stuttgart.de": {},
|
||||
"hdm-stuttgart.de": {},
|
||||
}
|
||||
tests := []struct {
|
||||
domain string
|
||||
wantMatch bool
|
||||
wantEntry string
|
||||
}{
|
||||
{"uni-stuttgart.de", true, "uni-stuttgart.de"}, // exact match
|
||||
{"insti.uni-stuttgart.de", true, "uni-stuttgart.de"}, // single-level subdomain
|
||||
{"a.b.uni-stuttgart.de", true, "uni-stuttgart.de"}, // multi-level subdomain
|
||||
{"evil-uni-stuttgart.de", false, ""}, // lookalike non-match (different label)
|
||||
{"example.com", false, ""}, // not in set
|
||||
{"com", false, ""}, // single-label input
|
||||
{"uni-stuttgart.de.", true, "uni-stuttgart.de"}, // trailing dot normalised
|
||||
{"", false, ""}, // empty string
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, entry := matchesUniDomain(tc.domain, set)
|
||||
if got != tc.wantMatch || entry != tc.wantEntry {
|
||||
t.Errorf("matchesUniDomain(%q): got (%v, %q), want (%v, %q)",
|
||||
tc.domain, got, entry, tc.wantMatch, tc.wantEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"uni-stuttgart.de", "uni-stuttgart.de"}, // plain domain passthrough
|
||||
{"foo@uni-stuttgart.de", "uni-stuttgart.de"}, // email extraction
|
||||
{"FOO@UNI-STUTTGART.DE", "uni-stuttgart.de"}, // uppercase normalisation
|
||||
{" foo@uni-stuttgart.de ", "uni-stuttgart.de"}, // leading/trailing spaces
|
||||
{"notanemail", "notanemail"}, // no-@ passthrough
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := extractDomain(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("extractDomain(%q): got %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupDomainMatch(t *testing.T) {
|
||||
s := &server{
|
||||
nrenASNs: map[uint]struct{}{},
|
||||
uniDomains: map[string]struct{}{"uni-stuttgart.de": {}},
|
||||
}
|
||||
s.ready.Store(true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/lookup?domain=insti.uni-stuttgart.de", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.lookupHandler(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `"domain_match":true`) {
|
||||
t.Errorf("expected domain_match:true in %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"matched_domain":"uni-stuttgart.de"`) {
|
||||
t.Errorf("expected matched_domain in %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"nren":true`) {
|
||||
t.Errorf("expected nren:true in %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"asn_match":false`) {
|
||||
t.Errorf("expected asn_match:false in %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupEmailParam(t *testing.T) {
|
||||
s := &server{
|
||||
nrenASNs: map[uint]struct{}{},
|
||||
uniDomains: map[string]struct{}{"uni-stuttgart.de": {}},
|
||||
}
|
||||
s.ready.Store(true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/lookup?email=student%40insti.uni-stuttgart.de", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.lookupHandler(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `"domain_match":true`) {
|
||||
t.Errorf("expected domain_match:true in %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"matched_domain":"uni-stuttgart.de"`) {
|
||||
t.Errorf("expected matched_domain in %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"nren":true`) {
|
||||
t.Errorf("expected nren:true in %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupEmailPrecedence(t *testing.T) {
|
||||
s := &server{
|
||||
nrenASNs: map[uint]struct{}{},
|
||||
uniDomains: map[string]struct{}{"uni-stuttgart.de": {}},
|
||||
}
|
||||
s.ready.Store(true)
|
||||
|
||||
// email= takes precedence over domain=; example.com is not in uniDomains
|
||||
req := httptest.NewRequest(http.MethodGet, "/lookup?email=a%40uni-stuttgart.de&domain=example.com", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.lookupHandler(rr, req)
|
||||
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `"domain":"uni-stuttgart.de"`) {
|
||||
t.Errorf("expected domain uni-stuttgart.de (from email param) in %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"nren":true`) {
|
||||
t.Errorf("expected nren:true in %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthzJSON(t *testing.T) {
|
||||
s := &server{
|
||||
asnCount: 42,
|
||||
minASN: 10,
|
||||
uniDomains: map[string]struct{}{"uni-stuttgart.de": {}, "hdm-stuttgart.de": {}},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.healthzHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `"asn_count":42`) {
|
||||
t.Errorf("expected asn_count:42 in %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"domain_count":2`) {
|
||||
t.Errorf("expected domain_count:2 in %s", body)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Errorf("expected Content-Type application/json, got %s", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupEmailNoAt(t *testing.T) {
|
||||
s := &server{
|
||||
nrenASNs: map[uint]struct{}{},
|
||||
uniDomains: map[string]struct{}{},
|
||||
}
|
||||
s.ready.Store(true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/lookup?email=notanemail", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.lookupHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "email param has no @") {
|
||||
t.Fatalf("expected error message in response, got: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user