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) } } }