package main import ( "net/http" "net/http/httptest" "net/url" "path/filepath" "strconv" "strings" "testing" "time" ) const testPassword = "hunter2" func webConfig() Config { cfg := testConfig() cfg.WebPassword = testPassword return cfg } // newWebTestServer returns the full router plus the store behind it, so tests // can seed rows and assert on what the handlers wrote back. func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *Store) { t.Helper() store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("OpenStore: %v", err) } t.Cleanup(func() { store.Close() }) return newRouter(store, cfg), store } // sessionCookie returns a cookie a handler will accept for cfg's API token. func sessionCookie(t *testing.T, cfg Config) *http.Cookie { t.Helper() return &http.Cookie{ Name: sessionCookieName, Value: signSession(sessionKey(cfg.Token), time.Now().Add(time.Hour).UnixMilli()), } } func TestIndexWithoutSessionShowsLogin(t *testing.T) { srv, _ := newWebTestServer(t, webConfig()) rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) if rr.Code != http.StatusOK { t.Fatalf("GET / status = %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), `type="password"`) { t.Fatal("GET / without a session did not render the password field") } } func TestIndexWithSessionShowsList(t *testing.T) { cfg := webConfig() srv, store := newWebTestServer(t, cfg) if _, err := store.Upsert(Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, UpdatedAt: time.Now().UnixMilli(), }); err != nil { t.Fatalf("Upsert: %v", err) } req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(sessionCookie(t, cfg)) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("GET / status = %d, want 200", rr.Code) } if !strings.Contains(rr.Body.String(), "Solo Leveling") { t.Fatal("GET / with a session did not render the bookmark title") } } func TestLoginSuccessSetsCookie(t *testing.T) { srv, _ := newWebTestServer(t, webConfig()) req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(url.Values{"password": {testPassword}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Fatalf("POST /login status = %d, want 303", rr.Code) } cookies := rr.Result().Cookies() if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" { t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName) } } func TestLoginWrongPassword(t *testing.T) { srv, _ := newWebTestServer(t, webConfig()) req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("POST /login status = %d, want 401", rr.Code) } if len(rr.Result().Cookies()) != 0 { t.Fatal("a failed login set a cookie") } } func TestLoginRateLimited(t *testing.T) { srv, _ := newWebTestServer(t, webConfig()) post := func() *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("X-Forwarded-For", "203.0.113.9") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) return rr } for i := 0; i < loginMaxFailures; i++ { if code := post().Code; code != http.StatusUnauthorized { t.Fatalf("attempt %d status = %d, want 401", i+1, code) } } rr := post() if rr.Code != http.StatusTooManyRequests { t.Fatalf("attempt %d status = %d, want 429", loginMaxFailures+1, rr.Code) } if after := rr.Header().Get("Retry-After"); after == "" { t.Fatal("429 response has no Retry-After header") } else if n, err := strconv.Atoi(after); err != nil || n <= 0 { t.Fatalf("Retry-After = %q, want a positive integer", after) } } func TestLogoutClearsCookie(t *testing.T) { cfg := webConfig() srv, _ := newWebTestServer(t, cfg) req := httptest.NewRequest(http.MethodPost, "/logout", nil) req.AddCookie(sessionCookie(t, cfg)) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Fatalf("POST /logout status = %d, want 303", rr.Code) } cookies := rr.Result().Cookies() if len(cookies) != 1 || cookies[0].MaxAge >= 0 { t.Fatalf("POST /logout cookies = %+v, want one expiring cookie", cookies) } } func TestWebDisabledWhenNoPassword(t *testing.T) { cfg := testConfig() // WebPassword empty srv, _ := newWebTestServer(t, cfg) rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) if rr.Code != http.StatusNotFound { t.Fatalf("GET / with WEB_PASSWORD unset = %d, want 404", rr.Code) } } func TestBookmarksAPIStillBearerOnly(t *testing.T) { cfg := webConfig() srv, _ := newWebTestServer(t, cfg) // A session cookie must not grant access to the userscript's JSON API. req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil) req.AddCookie(sessionCookie(t, cfg)) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("GET /bookmarks with only a cookie = %d, want 401", rr.Code) } // And the bearer token must still work. rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) if rr.Code != http.StatusOK { t.Fatalf("GET /bookmarks with bearer = %d, want 200", rr.Code) } } func TestStaticAssetsServed(t *testing.T) { srv, _ := newWebTestServer(t, webConfig()) for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js"} { rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) if rr.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", path, rr.Code) } if rr.Body.Len() == 0 { t.Fatalf("GET %s returned an empty body", path) } } } // seed inserts one bookmark and returns it as stored. func seed(t *testing.T, store *Store, b Bookmark) Bookmark { t.Helper() stored, err := store.Upsert(b) if err != nil { t.Fatalf("Upsert: %v", err) } return stored } func uiRequest(t *testing.T, cfg Config, method, path string, form url.Values) *http.Request { t.Helper() var req *http.Request if form == nil { req = httptest.NewRequest(method, path, nil) } else { req = httptest.NewRequest(method, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } req.AddCookie(sessionCookie(t, cfg)) return req } func TestUIRoutesRequireSession(t *testing.T) { srv, _ := newWebTestServer(t, webConfig()) cases := []struct{ method, path string }{ {http.MethodGet, "/ui/list"}, {http.MethodPost, "/ui/bookmarks/asura:solo/favorite"}, {http.MethodPost, "/ui/bookmarks/asura:solo/chapter"}, {http.MethodDelete, "/ui/bookmarks/asura:solo"}, } for _, tc := range cases { t.Run(tc.method+" "+tc.path, func(t *testing.T) { rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil)) if rr.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rr.Code) } }) } } func TestFavoriteTogglesWithoutReordering(t *testing.T) { cfg := webConfig() srv, store := newWebTestServer(t, cfg) before := seed(t, store, Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, UpdatedAt: 1_000_000, }) rr := httptest.NewRecorder() srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil)) if rr.Code != http.StatusOK { t.Fatalf("favorite status = %d, want 200", rr.Code) } after, ok, err := store.Get("asura:solo") if err != nil || !ok { t.Fatalf("Get after favorite: %v ok=%v", err, ok) } if !after.Favorite { t.Fatal("Favorite = false after toggling, want true") } if after.UpdatedAt != before.UpdatedAt { t.Fatalf("UpdatedAt moved from %d to %d; favouriting must not reorder the list", before.UpdatedAt, after.UpdatedAt) } if !strings.Contains(rr.Body.String(), `id="card-asura:solo"`) { t.Fatal("favorite response did not render the card fragment") } // Toggling again turns it back off. rr = httptest.NewRecorder() srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil)) back, _, _ := store.Get("asura:solo") if back.Favorite { t.Fatal("Favorite = true after a second toggle, want false") } } func TestChapterOverrideMovesUpdatedAt(t *testing.T) { cfg := webConfig() srv, store := newWebTestServer(t, cfg) before := seed(t, store, Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo", UpdatedAt: 1_000_000, }) rr := httptest.NewRecorder() srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"60"}})) if rr.Code != http.StatusOK { t.Fatalf("chapter override status = %d, want 200", rr.Code) } after, ok, err := store.Get("asura:solo") if err != nil || !ok { t.Fatalf("Get after override: %v ok=%v", err, ok) } if after.LastChapterNum != 60 || after.LastChapter != "60" { t.Fatalf("chapter = %q/%v, want 60", after.LastChapter, after.LastChapterNum) } if after.UpdatedAt <= before.UpdatedAt { t.Fatalf("UpdatedAt = %d, want later than %d", after.UpdatedAt, before.UpdatedAt) } if after.LastChapterURL != "" { t.Fatalf("LastChapterURL = %q, want cleared by a manual override", after.LastChapterURL) } if after.Title != "Solo Leveling" { t.Fatalf("Title = %q, want the untouched fields preserved", after.Title) } } func TestChapterOverrideRejectsBadInput(t *testing.T) { cfg := webConfig() srv, store := newWebTestServer(t, cfg) seed(t, store, Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000, }) for _, bad := range []string{"", "abc", "-3"} { t.Run("input "+bad, func(t *testing.T) { rr := httptest.NewRecorder() srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {bad}})) if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } after, _, _ := store.Get("asura:solo") if after.LastChapterNum != 45 { t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum) } }) } } func TestMutationsOnMissingKey(t *testing.T) { cfg := webConfig() srv, _ := newWebTestServer(t, cfg) cases := []struct { name string req *http.Request }{ {"favorite", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/favorite", nil)}, {"chapter", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/chapter", url.Values{"chapter": {"1"}})}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rr := httptest.NewRecorder() srv.ServeHTTP(rr, tc.req) if rr.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rr.Code) } }) } } func TestUIDeleteRemovesRow(t *testing.T) { cfg := webConfig() srv, store := newWebTestServer(t, cfg) seed(t, store, Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", UpdatedAt: 1_000_000, }) rr := httptest.NewRecorder() srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodDelete, "/ui/bookmarks/asura:solo", nil)) if rr.Code != http.StatusOK { t.Fatalf("delete status = %d, want 200", rr.Code) } if rr.Body.Len() != 0 { t.Fatalf("delete body = %q, want empty so htmx swaps the card away", rr.Body.String()) } if _, ok, _ := store.Get("asura:solo"); ok { t.Fatal("row still present after delete") } } func TestUIListFavouritesTab(t *testing.T) { cfg := webConfig() srv, store := newWebTestServer(t, cfg) seed(t, store, Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000, }) seed(t, store, Bookmark{ Key: "demonic:tower", Site: "demonic", SeriesID: "tower", Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000, }) rr := httptest.NewRecorder() srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=fav", nil)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rr.Code) } body := rr.Body.String() if !strings.Contains(body, "Solo Leveling") { t.Fatal("favourites tab omitted the favourited series") } if strings.Contains(body, "Tower of God") { t.Fatal("favourites tab included a non-favourite") } }