mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 05:24:36 +03:00
393 lines
12 KiB
Go
393 lines
12 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// newTestServerWithProfiles writes profiles.json into a temp dir and
|
|
// returns a minimal *Server pointed at it. The clients map is a
|
|
// non-nil empty map so broadcast() doesn't blow up on a nil iteration.
|
|
func newTestServerWithProfiles(t *testing.T, pl *ProfileList) *Server {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
s := &Server{dataDir: dir, clients: map[chan string]struct{}{}}
|
|
if pl != nil {
|
|
if err := s.saveProfiles(pl); err != nil {
|
|
t.Fatalf("save initial profiles: %v", err)
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// loadProfilesT is the test-side reload helper.
|
|
func loadProfilesT(t *testing.T, s *Server) *ProfileList {
|
|
t.Helper()
|
|
pl, err := s.loadProfiles()
|
|
if err != nil {
|
|
t.Fatalf("loadProfiles: %v", err)
|
|
}
|
|
return pl
|
|
}
|
|
|
|
func TestPruneResolverFromLists(t *testing.T) {
|
|
pl := &ProfileList{
|
|
ActiveLists: []ActiveList{
|
|
{Name: "Home", Resolvers: []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"}},
|
|
{Name: "Work", Resolvers: []string{"8.8.8.8:53", "9.9.9.9:53"}},
|
|
{Name: "Empty"},
|
|
},
|
|
}
|
|
if !pruneResolverFromLists(pl, "8.8.8.8:53") {
|
|
t.Fatal("expected change reported")
|
|
}
|
|
got := pl.ActiveLists[0].Resolvers
|
|
if len(got) != 2 || got[0] != "1.1.1.1:53" || got[1] != "9.9.9.9:53" {
|
|
t.Errorf("Home list = %v, want [1.1.1.1:53 9.9.9.9:53]", got)
|
|
}
|
|
if len(pl.ActiveLists[1].Resolvers) != 1 {
|
|
t.Errorf("Work list = %v, want 1 resolver", pl.ActiveLists[1].Resolvers)
|
|
}
|
|
// Pruning a resolver no one references is a no-op.
|
|
if pruneResolverFromLists(pl, "10.0.0.1:53") {
|
|
t.Error("expected no-op change=false for unknown resolver")
|
|
}
|
|
// Empty / nil inputs.
|
|
if pruneResolverFromLists(nil, "x") {
|
|
t.Error("nil profile should not report change")
|
|
}
|
|
if pruneResolverFromLists(pl, "") {
|
|
t.Error("empty resolver should not report change")
|
|
}
|
|
}
|
|
|
|
func TestPruneResolversFromListsBatch(t *testing.T) {
|
|
pl := &ProfileList{
|
|
ActiveLists: []ActiveList{
|
|
{Name: "A", Resolvers: []string{"a", "b", "c"}},
|
|
{Name: "B", Resolvers: []string{"d", "e"}},
|
|
},
|
|
}
|
|
removed := map[string]bool{"a": true, "c": true, "e": true}
|
|
if !pruneResolversFromLists(pl, removed) {
|
|
t.Fatal("expected change reported")
|
|
}
|
|
if got := strings.Join(pl.ActiveLists[0].Resolvers, ","); got != "b" {
|
|
t.Errorf("A list = %q, want %q", got, "b")
|
|
}
|
|
if got := strings.Join(pl.ActiveLists[1].Resolvers, ","); got != "d" {
|
|
t.Errorf("B list = %q, want %q", got, "d")
|
|
}
|
|
}
|
|
|
|
func TestFindListCaseInsensitive(t *testing.T) {
|
|
pl := &ProfileList{
|
|
ActiveLists: []ActiveList{
|
|
{Name: "Home WiFi"},
|
|
{Name: "office"},
|
|
},
|
|
}
|
|
if got := findList(pl, "home wifi"); got == nil || got.Name != "Home WiFi" {
|
|
t.Errorf("findList lowercase = %v", got)
|
|
}
|
|
if got := findList(pl, " OFFICE "); got == nil || got.Name != "office" {
|
|
t.Errorf("findList trimmed/upper = %v", got)
|
|
}
|
|
if got := findList(pl, "missing"); got != nil {
|
|
t.Errorf("findList missing = %v, want nil", got)
|
|
}
|
|
if got := findList(nil, "x"); got != nil {
|
|
t.Errorf("findList nil pl = %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeListName(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{" Home ", "Home"},
|
|
{"", ""},
|
|
{" ", ""},
|
|
// 33 chars → trimmed to 32.
|
|
{strings.Repeat("x", 33), strings.Repeat("x", 32)},
|
|
}
|
|
for _, c := range cases {
|
|
if got := sanitizeListName(c.in); got != c.want {
|
|
t.Errorf("sanitizeListName(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMigrateActiveLists verifies that legacy installs (no ActiveLists,
|
|
// non-empty ResolverBank) get a "Default" list seeded from the bank
|
|
// when migrateActiveLists runs. We exercise it via a fake Server with
|
|
// just enough wiring.
|
|
func TestMigrateActiveListsFromBank(t *testing.T) {
|
|
pl := &ProfileList{
|
|
ResolverBank: []string{"1.1.1.1:53", "8.8.8.8:53"},
|
|
}
|
|
s := &Server{} // dataDir empty → loadLastScan returns nil
|
|
if !s.migrateActiveLists(pl) {
|
|
t.Fatal("expected migration to seed a list")
|
|
}
|
|
if len(pl.ActiveLists) != 1 || pl.ActiveLists[0].Name != defaultListName {
|
|
t.Fatalf("ActiveLists = %v", pl.ActiveLists)
|
|
}
|
|
if pl.SelectedList != defaultListName {
|
|
t.Errorf("SelectedList = %q, want %q", pl.SelectedList, defaultListName)
|
|
}
|
|
if len(pl.ActiveLists[0].Resolvers) != 2 {
|
|
t.Errorf("Default list resolvers = %v", pl.ActiveLists[0].Resolvers)
|
|
}
|
|
}
|
|
|
|
func TestMigrateActiveListsNoBankNoLists(t *testing.T) {
|
|
pl := &ProfileList{}
|
|
s := &Server{}
|
|
if s.migrateActiveLists(pl) {
|
|
t.Error("expected migration to be a no-op when nothing to seed")
|
|
}
|
|
if len(pl.ActiveLists) != 0 {
|
|
t.Errorf("ActiveLists = %v, want empty", pl.ActiveLists)
|
|
}
|
|
}
|
|
|
|
func TestMigrateActiveListsRepairsSelection(t *testing.T) {
|
|
pl := &ProfileList{
|
|
ActiveLists: []ActiveList{
|
|
{Name: "Home", Resolvers: []string{"a"}},
|
|
},
|
|
SelectedList: "Missing",
|
|
}
|
|
s := &Server{}
|
|
if !s.migrateActiveLists(pl) {
|
|
t.Fatal("expected SelectedList repair to count as a change")
|
|
}
|
|
if pl.SelectedList != "Home" {
|
|
t.Errorf("SelectedList = %q, want %q", pl.SelectedList, "Home")
|
|
}
|
|
}
|
|
|
|
// ===== persistLastScanToProfiles =====
|
|
|
|
func TestPersistLastScanSeedsEmptyListAndBank(t *testing.T) {
|
|
s := newTestServerWithProfiles(t, &ProfileList{
|
|
ActiveLists: []ActiveList{{Name: "Home", Resolvers: nil}},
|
|
SelectedList: "Home",
|
|
})
|
|
s.persistLastScanToProfiles([]string{"1.1.1.1:53", "8.8.8.8:53"})
|
|
pl := loadProfilesT(t, s)
|
|
if got := pl.ActiveLists[0].Resolvers; len(got) != 2 {
|
|
t.Errorf("Home list = %v, want 2 entries", got)
|
|
}
|
|
if got := pl.ResolverBank; len(got) != 2 {
|
|
t.Errorf("ResolverBank = %v, want 2 entries", got)
|
|
}
|
|
}
|
|
|
|
func TestPersistLastScanLeavesPopulatedListAlone(t *testing.T) {
|
|
s := newTestServerWithProfiles(t, &ProfileList{
|
|
ActiveLists: []ActiveList{{Name: "Home", Resolvers: []string{"existing:53"}}},
|
|
SelectedList: "Home",
|
|
ResolverBank: []string{"existing:53"},
|
|
})
|
|
s.persistLastScanToProfiles([]string{"new:53"})
|
|
pl := loadProfilesT(t, s)
|
|
if got := pl.ActiveLists[0].Resolvers; len(got) != 1 || got[0] != "existing:53" {
|
|
t.Errorf("Home list mutated = %v, expected unchanged", got)
|
|
}
|
|
if got := pl.ResolverBank; len(got) != 1 || got[0] != "existing:53" {
|
|
t.Errorf("ResolverBank mutated = %v, expected unchanged", got)
|
|
}
|
|
}
|
|
|
|
func TestPersistLastScanIgnoresEmptyInput(t *testing.T) {
|
|
s := newTestServerWithProfiles(t, &ProfileList{
|
|
ActiveLists: []ActiveList{{Name: "Home"}}, SelectedList: "Home",
|
|
})
|
|
s.persistLastScanToProfiles(nil)
|
|
s.persistLastScanToProfiles([]string{})
|
|
pl := loadProfilesT(t, s)
|
|
if len(pl.ActiveLists[0].Resolvers) != 0 {
|
|
t.Errorf("expected list still empty, got %v", pl.ActiveLists[0].Resolvers)
|
|
}
|
|
}
|
|
|
|
// ===== persistScanResultsToList =====
|
|
|
|
func TestPersistScanResultsPopulatesEmptyList(t *testing.T) {
|
|
s := newTestServerWithProfiles(t, &ProfileList{
|
|
ActiveLists: []ActiveList{{Name: "Home"}},
|
|
SelectedList: "Home",
|
|
})
|
|
s.persistScanResultsToList([]string{"a:53", "b:53"})
|
|
pl := loadProfilesT(t, s)
|
|
if got := pl.ActiveLists[0].Resolvers; len(got) != 2 {
|
|
t.Errorf("Home list = %v, want 2 entries", got)
|
|
}
|
|
}
|
|
|
|
func TestPersistScanResultsKeepsPopulatedListByDefault(t *testing.T) {
|
|
s := newTestServerWithProfiles(t, &ProfileList{
|
|
ActiveLists: []ActiveList{{Name: "Home", Resolvers: []string{"keep:53", "stay:53"}}},
|
|
SelectedList: "Home",
|
|
})
|
|
// rescanReplaceList is false → must NOT shrink the saved list
|
|
// when the periodic checker happens to find fewer healthy.
|
|
s.persistScanResultsToList([]string{"keep:53"})
|
|
pl := loadProfilesT(t, s)
|
|
if got := pl.ActiveLists[0].Resolvers; len(got) != 2 {
|
|
t.Errorf("populated list got shrunk to %v, want both kept", got)
|
|
}
|
|
}
|
|
|
|
func TestPersistScanResultsRescanOverwrites(t *testing.T) {
|
|
s := newTestServerWithProfiles(t, &ProfileList{
|
|
ActiveLists: []ActiveList{{Name: "Home", Resolvers: []string{"old1:53", "old2:53"}}},
|
|
SelectedList: "Home",
|
|
})
|
|
s.rescanFlagMu.Lock()
|
|
s.rescanReplaceList = true
|
|
s.rescanFlagMu.Unlock()
|
|
s.persistScanResultsToList([]string{"new:53"})
|
|
pl := loadProfilesT(t, s)
|
|
if got := pl.ActiveLists[0].Resolvers; len(got) != 1 || got[0] != "new:53" {
|
|
t.Errorf("Home list = %v, want [new:53]", got)
|
|
}
|
|
// Flag should be one-shot.
|
|
s.rescanFlagMu.Lock()
|
|
cleared := !s.rescanReplaceList
|
|
s.rescanFlagMu.Unlock()
|
|
if !cleared {
|
|
t.Error("rescanReplaceList not cleared after consume")
|
|
}
|
|
}
|
|
|
|
func TestPersistScanResultsSeedsDefaultListOnFirstRun(t *testing.T) {
|
|
s := newTestServerWithProfiles(t, &ProfileList{})
|
|
s.persistScanResultsToList([]string{"a:53", "b:53"})
|
|
pl := loadProfilesT(t, s)
|
|
if len(pl.ActiveLists) != 1 || pl.ActiveLists[0].Name != defaultListName {
|
|
t.Fatalf("ActiveLists = %v, want one Default list", pl.ActiveLists)
|
|
}
|
|
if got := pl.ActiveLists[0].Resolvers; len(got) != 2 {
|
|
t.Errorf("Default list = %v, want 2 entries", got)
|
|
}
|
|
if pl.SelectedList != defaultListName {
|
|
t.Errorf("SelectedList = %q, want %q", pl.SelectedList, defaultListName)
|
|
}
|
|
}
|
|
|
|
// ===== handleResolverListAdd =====
|
|
|
|
func TestHandleResolverListAdd(t *testing.T) {
|
|
s := newTestServerWithProfiles(t, &ProfileList{
|
|
ActiveLists: []ActiveList{
|
|
{Name: "Home", Resolvers: []string{"keep:53"}},
|
|
},
|
|
ResolverBank: []string{"keep:53", "new:53"},
|
|
SelectedList: "Other",
|
|
})
|
|
body, _ := json.Marshal(map[string]any{
|
|
"name": "Home",
|
|
"resolvers": []string{"new:53", "keep:53"}, // one new + one already in list
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/resolvers/lists/add", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
s.handleResolverListAdd(rec, req)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("handler status = %d, body %s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp struct {
|
|
Added int `json:"added"`
|
|
Count int `json:"count"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Added != 1 || resp.Count != 2 {
|
|
t.Errorf("response = %+v, want added=1 count=2", resp)
|
|
}
|
|
pl := loadProfilesT(t, s)
|
|
got := pl.ActiveLists[0].Resolvers
|
|
if len(got) != 2 || got[0] != "keep:53" || got[1] != "new:53" {
|
|
t.Errorf("Home list = %v, want [keep:53 new:53]", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleResolverListAddRejectsMissingList(t *testing.T) {
|
|
s := newTestServerWithProfiles(t, &ProfileList{})
|
|
body, _ := json.Marshal(map[string]any{"name": "Nope", "resolvers": []string{"a"}})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/resolvers/lists/add", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
s.handleResolverListAdd(rec, req)
|
|
if rec.Code != 404 {
|
|
t.Errorf("status = %d, want 404 for missing list", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleResolverListAddRejectsEmptyInput(t *testing.T) {
|
|
s := newTestServerWithProfiles(t, &ProfileList{
|
|
ActiveLists: []ActiveList{{Name: "Home"}},
|
|
})
|
|
cases := []map[string]any{
|
|
{"name": "", "resolvers": []string{"a"}}, // empty name
|
|
{"name": "Home", "resolvers": []string{}}, // empty list
|
|
}
|
|
for i, c := range cases {
|
|
body, _ := json.Marshal(c)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/resolvers/lists/add", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
s.handleResolverListAdd(rec, req)
|
|
if rec.Code != 400 {
|
|
t.Errorf("case %d: status = %d, want 400", i, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== writeListsInfo (with/without resolver addresses) =====
|
|
|
|
func TestWriteListsInfoIncludeResolvers(t *testing.T) {
|
|
type listEntry struct {
|
|
Name string `json:"name"`
|
|
Count int `json:"count"`
|
|
Resolvers []string `json:"resolvers"`
|
|
}
|
|
type listResp struct {
|
|
Lists []listEntry `json:"lists"`
|
|
}
|
|
|
|
s := newTestServerWithProfiles(t, &ProfileList{
|
|
ActiveLists: []ActiveList{
|
|
{Name: "Home", Resolvers: []string{"a", "b"}},
|
|
},
|
|
SelectedList: "Home",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
s.writeListsInfo(rec, true)
|
|
var withAddrs listResp
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &withAddrs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(withAddrs.Lists) != 1 || withAddrs.Lists[0].Count != 2 || len(withAddrs.Lists[0].Resolvers) != 2 {
|
|
t.Errorf("with-resolvers response = %+v", withAddrs)
|
|
}
|
|
|
|
// Default (no flag) omits the addresses. Use a *separate* resp
|
|
// var — Go's json.Unmarshal leaves untouched fields untouched
|
|
// when reusing a populated struct, so reusing the previous
|
|
// `withAddrs` would falsely show Resolvers carried over.
|
|
rec = httptest.NewRecorder()
|
|
s.writeListsInfo(rec)
|
|
var noAddrs listResp
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &noAddrs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(noAddrs.Lists) != 1 || len(noAddrs.Lists[0].Resolvers) != 0 {
|
|
t.Errorf("default response leaked addresses: %+v", noAddrs)
|
|
}
|
|
}
|
|
|