Files
selfpost/internal/web/handlers_auth_test.go
T
mix 538a4b6603 feat: implement B.1 — persist login sessions in SQLite with sliding idle timeout
Sessions move from an in-memory map (absolute 12h TTL) to a `sessions`
table (migration 0002), storing only the SHA-256 of the token. Expiry is
now a sliding idle window (PANEL_SESSION_IDLE_DAYS, default 7, no
absolute cap), extended at most once an hour and never by the
monitoring screens' background polling (GET + HX-Request), so a
forgotten open tab doesn't keep a session alive indefinitely. A login
now survives a container restart or redeploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 23:21:49 +03:00

113 lines
4.3 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The __Host- prefix is only valid on a Secure cookie: getting this condition
// backwards would make the development instance fail to log in at all, and
// silently — the browser discards the Set-Cookie and the panel just shows the
// login form again.
func TestSessionCookieNameFollowsCookieSecure(t *testing.T) {
secure := &Server{cfg: Config{CookieSecure: true}}
if got := secure.sessionCookie(); got != "__Host-selfpost_session" {
t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got)
}
plain := &Server{cfg: Config{CookieSecure: false}}
if got := plain.sessionCookie(); got != "selfpost_session" {
t.Errorf("without TLS the cookie is named %q, want the bare name", got)
}
}
// A neighbouring host on the same registrable domain can set a cookie by the
// same name; the browser then sends both, oldest first. Picking one at random
// would leave the administrator in a login loop with no explanation, so the
// request counts as signed out instead.
func TestSessionTokenRejectsDuplicates(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: false}}
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"})
if token, ok := s.sessionToken(r); ok {
t.Fatalf("duplicate cookies accepted, token = %q", token)
}
}
func TestSessionTokenReadsOneCookie(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: true}}
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"})
token, ok := s.sessionToken(r)
if !ok || token != "the-real-session" {
t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok)
}
}
// A cookie under the other deployment's name is not this deployment's session:
// after an upgrade the pre-14 cookie must not be honoured as if it were the
// prefixed one.
func TestSessionTokenIgnoresTheOtherName(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: true}}
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"})
if _, ok := s.sessionToken(r); ok {
t.Fatal("the unprefixed cookie was accepted on a TLS deployment")
}
}
func TestRequireAuthRejectsDuplicateCookies(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: false}, sessions: newTestSessionStore(t)}
token := s.sessions.Create("admin")
reached := false
h := s.requireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil)
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"})
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: token})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if reached {
t.Fatal("the handler ran even though the session cookie was shadowed")
}
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" {
t.Fatalf("status = %d, Location = %q; want a redirect to /login", rec.Code, rec.Header().Get("Location"))
}
}
// Signing out has to expire the cookie under both names, or the cookie left
// over from a pre-__Host- build stays in the browser for the rest of its life.
func TestLogoutClearsBothCookieNames(t *testing.T) {
s := &Server{cfg: Config{CookieSecure: true}, sessions: newTestSessionStore(t)}
token := s.sessions.Create("admin")
r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil)
r.Host = "panel.example.com"
r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token})
rec := httptest.NewRecorder()
s.handleLogout(rec, r)
if _, ok := s.sessions.Lookup(token); ok {
t.Error("the session survived sign-out")
}
set := rec.Header().Values("Set-Cookie")
for _, name := range []string{"selfpost_session=", "__Host-selfpost_session="} {
var found bool
for _, c := range set {
if strings.HasPrefix(c, name) && strings.Contains(c, "Max-Age=0") {
found = true
}
}
if !found {
t.Errorf("sign-out does not expire a cookie named %q: %v", strings.TrimSuffix(name, "="), set)
}
}
}