Files
education-flagger/main_test.go
Robert Rapp 082ecc579a 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>
2026-03-17 15:10:49 +01:00

209 lines
6.0 KiB
Go

package main
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestLookupMissingDomain(t *testing.T) {
s := &server{
nrenASNs: make(map[uint]struct{}),
}
s.ready.Store(true)
req := httptest.NewRequest(http.MethodGet, "/lookup", nil)
rr := httptest.NewRecorder()
s.lookupHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "missing domain") {
t.Fatalf("expected error message in response")
}
}
func TestLookupServiceNotReady(t *testing.T) {
s := &server{
nrenASNs: make(map[uint]struct{}),
}
s.ready = atomic.Bool{}
s.ready.Store(false)
req := httptest.NewRequest(http.MethodGet, "/lookup?domain=example.com", nil)
rr := httptest.NewRecorder()
s.lookupHandler(rr, req)
if rr.Code != http.StatusServiceUnavailable {
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())
}
}