feat(backend): password login, session gate, and list page
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,17 @@ func newRouter(store *Store, cfg Config) http.Handler {
|
|||||||
mux.Handle("/bookmarks", auth)
|
mux.Handle("/bookmarks", auth)
|
||||||
mux.Handle("/bookmarks/", auth)
|
mux.Handle("/bookmarks/", auth)
|
||||||
|
|
||||||
|
// The browser UI is registered only when a password is configured, so a
|
||||||
|
// deployment that forgets WEB_PASSWORD exposes nothing rather than
|
||||||
|
// exposing an unprotected list.
|
||||||
|
if cfg.WebPassword != "" {
|
||||||
|
web, err := newWebHandler(store, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("web handler: %v", err)
|
||||||
|
}
|
||||||
|
web.register(mux)
|
||||||
|
}
|
||||||
|
|
||||||
return withCORS(cfg.AllowedOrigins, mux)
|
return withCORS(cfg.AllowedOrigins, mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Title search and tab-state handling land in Task 7.
|
||||||
|
function setActiveTab(el) {
|
||||||
|
el.parentElement.querySelectorAll("[role=tab]").forEach(function (t) {
|
||||||
|
t.classList.toggle("active", t === el);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* Styling lands in Task 7. */
|
||||||
|
:root { color-scheme: dark light; }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{{define "app"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<title>mangaBookmark</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
|
<script src="/static/filter.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>mangaBookmark</h1>
|
||||||
|
<form method="post" action="/logout">
|
||||||
|
<button type="submit" class="ghost">Log out</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<input id="search" class="search" type="search" placeholder="Search titles…"
|
||||||
|
autocomplete="off" aria-label="Search titles">
|
||||||
|
|
||||||
|
<nav class="tabs" role="tablist">
|
||||||
|
<a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
|
||||||
|
hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML"
|
||||||
|
hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
|
||||||
|
<a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
|
||||||
|
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
|
||||||
|
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{{if .Recent}}
|
||||||
|
<section class="recent">
|
||||||
|
<h2>Continue reading</h2>
|
||||||
|
<div class="recent-strip">
|
||||||
|
{{range .Recent}}
|
||||||
|
<a class="recent-card" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">{{end}}
|
||||||
|
<span class="recent-title">{{.Title}}</span>
|
||||||
|
<span class="recent-chapter">Ch {{.LastChapter}}</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<main id="list" class="list">
|
||||||
|
{{template "list" .}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{define "card"}}
|
||||||
|
<article class="card" id="card-{{.Key}}" data-title="{{.Title}}">
|
||||||
|
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">{{end}}
|
||||||
|
</a>
|
||||||
|
<div class="body">
|
||||||
|
<h3 class="title">{{.Title}}</h3>
|
||||||
|
<p class="meta">
|
||||||
|
<span class="site site-{{.Site}}">{{.Site}}</span>
|
||||||
|
<span class="chapter">Ch {{.LastChapter}}</span>
|
||||||
|
{{if .HasNewChapter}}<span class="new">NEW {{.LatestChapter}}</span>{{end}}
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">Continue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{{define "list"}}
|
||||||
|
{{if .Items}}
|
||||||
|
{{range .Items}}{{template "card" .}}{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">
|
||||||
|
Nothing here yet. Bookmarks appear once the userscript records a chapter.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{define "login"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<title>mangaBookmark</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<main class="login-card">
|
||||||
|
<h1>mangaBookmark</h1>
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password"
|
||||||
|
autocomplete="current-password" autofocus required>
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates
|
||||||
|
var templateFS embed.FS
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
// recentCount is how many series the "Continue reading" strip shows.
|
||||||
|
const recentCount = 5
|
||||||
|
|
||||||
|
// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
||||||
|
// It is a separate handler from bookmarkHandler because the two speak different
|
||||||
|
// representations (HTML versus JSON) to different clients under different auth.
|
||||||
|
type webHandler struct {
|
||||||
|
store *Store
|
||||||
|
tmpl *template.Template
|
||||||
|
key []byte
|
||||||
|
password string
|
||||||
|
limiter *loginLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// listView is what every list-rendering template receives.
|
||||||
|
type listView struct {
|
||||||
|
Tab string // "all" or "fav"
|
||||||
|
Recent []Bookmark
|
||||||
|
Items []Bookmark
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginView is what the login template receives.
|
||||||
|
type loginView struct {
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWebHandler parses every template up front so a broken one kills the
|
||||||
|
// process at startup rather than the first request that touches it.
|
||||||
|
func newWebHandler(store *Store, cfg Config) (*webHandler, error) {
|
||||||
|
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &webHandler{
|
||||||
|
store: store,
|
||||||
|
tmpl: tmpl,
|
||||||
|
key: sessionKey(cfg.Token),
|
||||||
|
password: cfg.WebPassword,
|
||||||
|
limiter: newLoginLimiter(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *webHandler) register(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /{$}", h.index)
|
||||||
|
mux.HandleFunc("POST /login", h.login)
|
||||||
|
mux.HandleFunc("POST /logout", h.logout)
|
||||||
|
mux.Handle("GET /static/", staticHandler())
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList))
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticHandler serves the embedded assets. The vendored htmx build and the
|
||||||
|
// stylesheet change only on deploy, so a long max-age is safe; a redeploy
|
||||||
|
// changes the binary and the browser revalidates on its own schedule.
|
||||||
|
func staticHandler() http.Handler {
|
||||||
|
sub, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
panic("embed static: " + err.Error())
|
||||||
|
}
|
||||||
|
files := http.FileServer(http.FS(sub))
|
||||||
|
return http.StripPrefix("/static/", http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// authed reports whether the request carries a valid session cookie.
|
||||||
|
func (h *webHandler) authed(r *http.Request) bool {
|
||||||
|
c, err := r.Cookie(sessionCookieName)
|
||||||
|
return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli())
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireSession guards the fragment endpoints. It answers 401 rather than
|
||||||
|
// redirecting, because htmx swaps whatever body it receives into the page and a
|
||||||
|
// redirected login page would be spliced into the card list.
|
||||||
|
func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.authed(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *webHandler) render(w http.ResponseWriter, status int, name string, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
// The status line is already sent, so this can only be logged.
|
||||||
|
log.Printf("render %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// index renders the list, or the login page when there is no session. The login
|
||||||
|
// page is served at / with status 200 rather than as a redirect to a separate
|
||||||
|
// URL: one page, no redirect loop to reason about.
|
||||||
|
func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.authed(r) {
|
||||||
|
h.render(w, http.StatusOK, "login", loginView{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("index: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, http.StatusOK, "app", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildListView loads the list once and derives both the tab-filtered items and
|
||||||
|
// the recent strip from it. The strip always reflects overall recency, not the
|
||||||
|
// active tab, so it is built before filtering.
|
||||||
|
func (h *webHandler) buildListView(tab string) (listView, error) {
|
||||||
|
all, err := h.store.List() // already ordered updated_at DESC
|
||||||
|
if err != nil {
|
||||||
|
return listView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
recent := all
|
||||||
|
if len(recent) > recentCount {
|
||||||
|
recent = recent[:recentCount]
|
||||||
|
}
|
||||||
|
|
||||||
|
items := all
|
||||||
|
if tab == "fav" {
|
||||||
|
items = []Bookmark{}
|
||||||
|
for _, b := range all {
|
||||||
|
if b.Favorite {
|
||||||
|
items = append(items, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tab = "all"
|
||||||
|
}
|
||||||
|
return listView{Tab: tab, Recent: recent, Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ui list: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, http.StatusOK, "list", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := clientIP(r)
|
||||||
|
if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 {
|
||||||
|
secs := int(wait.Seconds()) + 1
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||||
|
h.render(w, http.StatusTooManyRequests, "login", loginView{
|
||||||
|
Error: "Too many attempts. Try again in " +
|
||||||
|
strconv.Itoa((secs+59)/60) + " min.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got := r.PostFormValue("password")
|
||||||
|
if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 {
|
||||||
|
h.limiter.fail(ip, time.Now())
|
||||||
|
h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.limiter.reset(ip)
|
||||||
|
setSessionCookie(w, r, h.key)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clearSessionCookie(w, r)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user