internal/server/auth_test.go
Rama main · réplica de lectura del repositorio original.
package server
import (
"crypto/tls"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"armdash/internal/auth"
"armdash/internal/config"
"armdash/internal/system"
)
// One derivation costs a noticeable fraction of a second, so the tests share
// one hash.
var testHash = sync.OnceValue(func() string {
h, err := auth.Hash("correct horse")
if err != nil {
panic(err)
}
return h
})
func newAuthServer(t *testing.T) *Server {
t.Helper()
t.Setenv("AD_CORE_AUTH_USER", "owner")
t.Setenv("AD_CORE_AUTH_PASSWORD_HASH", testHash())
return newTestServer(t)
}
func loginRequest(user, password, next string) *http.Request {
form := url.Values{"user": {user}, "password": {password}, "next": {next}}
r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
func do(s *Server, r *http.Request, cookies ...*http.Cookie) *httptest.ResponseRecorder {
for _, c := range cookies {
r.AddCookie(c)
}
rec := httptest.NewRecorder()
s.ServeHTTP(rec, r)
return rec
}
func sessionFrom(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == sessionCookie {
return c
}
}
t.Fatalf("no %s cookie in the response (%d)", sessionCookie, rec.Code)
return nil
}
func TestLoginSetsTheSessionCookie(t *testing.T) {
s := newAuthServer(t)
rec := do(s, loginRequest("owner", "correct horse", "/settings"))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings" {
t.Fatalf("login = %d to %q, want 303 back to /settings", rec.Code, rec.Header().Get("Location"))
}
c := sessionFrom(t, rec)
if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.Secure || c.MaxAge != 7*24*3600 {
t.Errorf("cookie %+v, want HttpOnly, Lax, path /, 7 days, not Secure over plain HTTP", c)
}
r := loginRequest("owner", "correct horse", "/")
r.TLS = &tls.ConnectionState{}
if c := sessionFrom(t, do(s, r)); !c.Secure {
t.Error("a login over TLS did not get a Secure cookie")
}
}
func TestWrongNameAndWrongPasswordLookTheSame(t *testing.T) {
s := newAuthServer(t)
for _, c := range []struct{ user, password string }{
{"owner", "wrong horse"},
{"stranger", "correct horse"},
{"", ""},
} {
rec := do(s, loginRequest(c.user, c.password, "/"))
if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "Wrong user name or password.") {
t.Errorf("%q/%q = %d, want 401 with the one message", c.user, c.password, rec.Code)
}
if len(rec.Result().Cookies()) != 0 {
t.Errorf("%q/%q set a cookie", c.user, c.password)
}
}
}
func TestFailedLoginsLockTheAddress(t *testing.T) {
s := newAuthServer(t)
s.limiter = auth.NewLimiter(2, time.Minute, time.Minute)
do(s, loginRequest("owner", "wrong horse", "/"))
do(s, loginRequest("stranger", "correct horse", "/"))
rec := do(s, loginRequest("owner", "correct horse", "/"))
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" {
t.Fatalf("the right password while locked = %d, want 429 with Retry-After", rec.Code)
}
other := loginRequest("owner", "correct horse", "/")
other.RemoteAddr = "198.51.100.9:4000"
if rec := do(s, other); rec.Code != http.StatusSeeOther {
t.Errorf("another address = %d, want it unaffected", rec.Code)
}
spoof := loginRequest("owner", "correct horse", "/")
spoof.Header.Set("X-Forwarded-For", "198.51.100.10")
if rec := do(s, spoof); rec.Code != http.StatusTooManyRequests {
t.Errorf("X-Forwarded-For got around the lock: %d", rec.Code)
}
}
func TestLogoutEndsTheSession(t *testing.T) {
s := newAuthServer(t)
c := sessionFrom(t, do(s, loginRequest("owner", "correct horse", "/")))
if rec := do(s, httptest.NewRequest(http.MethodGet, "/settings", nil), c); rec.Code != http.StatusOK {
t.Fatalf("settings while logged in = %d", rec.Code)
}
rec := do(s, httptest.NewRequest(http.MethodPost, "/logout", nil), c)
if rec.Code != http.StatusSeeOther || sessionFrom(t, rec).MaxAge >= 0 {
t.Fatalf("logout = %d, want 303 and the cookie deleted", rec.Code)
}
if rec := do(s, httptest.NewRequest(http.MethodGet, "/settings", nil), c); rec.Code != http.StatusFound {
t.Errorf("settings with the old cookie = %d, want the login redirect", rec.Code)
}
}
func TestLoginIsSameOriginOnly(t *testing.T) {
s := newAuthServer(t)
r := loginRequest("owner", "correct horse", "/")
r.Header.Set("Sec-Fetch-Site", "cross-site")
if rec := do(s, r); rec.Code != http.StatusForbidden {
t.Errorf("cross-site login = %d, want 403", rec.Code)
}
}
func TestReturnPathStaysLocal(t *testing.T) {
for next, want := range map[string]string{
"/settings": "/settings",
"/s/fritzhome/floorplan": "/s/fritzhome/floorplan",
"/s/host/cpu?range=7d": "/s/host/cpu?range=7d",
"": "/",
"settings": "/",
"//evil.example/": "/",
"/\\evil.example/": "/",
"/\t/evil.example/": "/",
"https://evil.example/": "/",
"javascript:alert(1)": "/",
"/login?next=/login": "/",
"/%2F%2Fevil.example/path": "/%2F%2Fevil.example/path",
} {
if got := localPath(next); got != want {
t.Errorf("localPath(%q) = %q, want %q", next, got, want)
}
}
}
func TestWritesNeedALogin(t *testing.T) {
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
h := newAuthServer(t).api("fritzhome", ok)
post := func() *http.Request { return httptest.NewRequest(http.MethodPost, "/s/fritzhome/api/positions", nil) }
serveAPI := func(h http.Handler, r *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
return rec
}
if rec := serveAPI(h, post()); rec.Code != http.StatusUnauthorized {
t.Errorf("POST without a session = %d, want 401", rec.Code)
}
if rec := serveAPI(h, system.AllowEdit(post())); rec.Code != http.StatusNoContent {
t.Errorf("POST with a session = %d, want it through", rec.Code)
}
if rec := serveAPI(h, httptest.NewRequest(http.MethodGet, "/s/fritzhome/api/floorplan", nil)); rec.Code != http.StatusNoContent {
t.Errorf("GET without a session = %d, want it through", rec.Code)
}
hx := post()
hx.Header.Set("HX-Request", "true")
if rec := serveAPI(h, hx); !strings.Contains(rec.Body.String(), `class="notification is-danger"`) {
t.Errorf("htmx got %q, want the notice for its message area", rec.Body.String())
}
t.Setenv("AD_CORE_AUTH_USER", "")
t.Setenv("AD_CORE_AUTH_PASSWORD_HASH", "")
open := newTestServer(t).api("fritzhome", ok)
if rec := serveAPI(open, system.AllowEdit(post())); rec.Code != http.StatusForbidden {
t.Errorf("POST with no login configured = %d, want 403", rec.Code)
}
}
func TestSettingsNeedALoginOnceOneIsConfigured(t *testing.T) {
s := newAuthServer(t)
rec := do(s, httptest.NewRequest(http.MethodGet, "/settings", nil))
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login?next=%2Fsettings" {
t.Fatalf("settings logged out = %d to %q", rec.Code, rec.Header().Get("Location"))
}
c := sessionFrom(t, do(s, loginRequest("owner", "correct horse", "/")))
body := do(s, httptest.NewRequest(http.MethodGet, "/settings", nil), c).Body.String()
if !strings.Contains(body, "owner") || !strings.Contains(body, "set in environment") {
t.Error("settings do not show the login and where it comes from")
}
if strings.Contains(body, testHash()) || strings.Contains(body, strings.Split(testHash(), ":")[3]) {
t.Error("settings show the password hash")
}
if rec := do(s, httptest.NewRequest(http.MethodGet, "/metrics", nil)); rec.Code != http.StatusOK {
t.Errorf("/metrics = %d, Prometheus has no login", rec.Code)
}
t.Setenv("AD_CORE_AUTH_USER", "")
t.Setenv("AD_CORE_AUTH_PASSWORD_HASH", "")
if rec := do(newTestServer(t), httptest.NewRequest(http.MethodGet, "/settings", nil)); rec.Code != http.StatusOK {
t.Errorf("settings with no login configured = %d, want them open as before", rec.Code)
}
}
func TestNavbarOffersLoginOrLogout(t *testing.T) {
s := newAuthServer(t)
out := do(s, httptest.NewRequest(http.MethodGet, "/login?next=/settings", nil)).Body.String()
if !strings.Contains(out, `href="/login?next=%2fsettings"`) || strings.Contains(out, "Log out") {
t.Error("a visitor is not offered the login")
}
c := sessionFrom(t, do(s, loginRequest("owner", "correct horse", "/")))
in := do(s, httptest.NewRequest(http.MethodGet, "/settings", nil), c).Body.String()
if !strings.Contains(in, `action="/logout"`) || strings.Contains(in, ">Log in<") {
t.Error("the owner is not offered the logout")
}
if rec := do(s, httptest.NewRequest(http.MethodGet, "/login?next=/settings", nil), c); rec.Header().Get("Location") != "/settings" {
t.Errorf("the login page while logged in = %q, want straight back", rec.Header().Get("Location"))
}
}
func TestLoginConfigMustBeComplete(t *testing.T) {
for name, c := range map[string]struct{ user, hash string }{
"user only": {"owner", ""},
"hash only": {"", testHash()},
"damaged": {"owner", "pbkdf2-sha256:600000:abc:def"},
"bcrypt": {"owner", "$2a$10$abcdefghijklmnopqrstuv"},
} {
t.Setenv("AD_CORE_AUTH_USER", c.user)
t.Setenv("AD_CORE_AUTH_PASSWORD_HASH", c.hash)
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
if _, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), nil); err == nil {
t.Errorf("%s: startup succeeded", name)
}
}
}