f70707f154
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
6.1 KiB
Go
202 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|