update_server/internal/http/auth_integration_test.go
2026-06-10 20:51:17 +03:00

269 lines
8 KiB
Go

package httpserver_test
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"update_server/internal/apikeys"
"update_server/internal/auth"
"update_server/internal/config"
"update_server/internal/db"
httpserver "update_server/internal/http"
"update_server/internal/releases"
"update_server/internal/storage"
)
const testCSRFCookieName = "update_server_csrf"
func TestAdminLoginLogoutFlowProtectsRoutes(t *testing.T) {
t.Parallel()
router, cfg := newTestRouter(t)
recorder := performRequest(t, router, http.MethodGet, "/admin", nil)
if recorder.Code != http.StatusSeeOther {
t.Fatalf("expected redirect for unauthenticated admin route, got %d", recorder.Code)
}
if location := recorder.Header().Get("Location"); location != "/admin/login" {
t.Fatalf("expected login redirect, got %q", location)
}
loginForm := url.Values{
"email": {"admin@example.com"},
"password": {"correct horse battery staple"},
"next": {"/admin"},
}
csrfCookie := ensureCSRFCookie(t, router)
loginForm.Set("csrf_token", csrfCookie.Value)
recorder = performRequest(t, router, http.MethodPost, "/admin/login", loginForm, csrfCookie)
if recorder.Code != http.StatusSeeOther {
t.Fatalf("expected login redirect, got %d", recorder.Code)
}
if location := recorder.Header().Get("Location"); location != "/admin" {
t.Fatalf("expected admin redirect after login, got %q", location)
}
adminCookies := recorder.Result().Cookies()
var sessionCookie *http.Cookie
for _, cookie := range adminCookies {
if cookie.Name == cfg.SessionCookieName {
sessionCookie = cookie
break
}
}
if sessionCookie == nil || sessionCookie.Value == "" {
t.Fatal("expected session cookie after successful login")
}
recorder = performRequest(t, router, http.MethodGet, "/admin", nil, sessionCookie)
if recorder.Code != http.StatusOK {
t.Fatalf("expected authenticated admin dashboard, got %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "Admin Dashboard") {
t.Fatal("expected admin dashboard content in response body")
}
if !strings.Contains(recorder.Body.String(), "admin@example.com") {
t.Fatal("expected authenticated admin email in dashboard response")
}
logoutCSRFCookie := ensureCSRFCookie(t, router, sessionCookie)
recorder = performRequest(t, router, http.MethodPost, "/admin/logout", url.Values{
"csrf_token": {logoutCSRFCookie.Value},
}, sessionCookie, logoutCSRFCookie)
if recorder.Code != http.StatusSeeOther {
t.Fatalf("expected logout redirect, got %d", recorder.Code)
}
if location := recorder.Header().Get("Location"); location != "/admin/login" {
t.Fatalf("expected login redirect after logout, got %q", location)
}
recorder = performRequest(t, router, http.MethodGet, "/admin", nil, sessionCookie)
if recorder.Code != http.StatusSeeOther {
t.Fatalf("expected invalidated session cookie to be rejected, got %d", recorder.Code)
}
if location := recorder.Header().Get("Location"); location != "/admin/login" {
t.Fatalf("expected invalidated session redirect, got %q", location)
}
}
func TestAdminLoginRejectsInvalidCredentials(t *testing.T) {
t.Parallel()
router, _ := newTestRouter(t)
csrfCookie := ensureCSRFCookie(t, router)
recorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{
"email": {"admin@example.com"},
"password": {"definitely-wrong"},
"next": {"/admin"},
"csrf_token": {csrfCookie.Value},
}, csrfCookie)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized login response, got %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "Invalid email or password.") {
t.Fatal("expected invalid login message in response body")
}
}
func newTestRouter(t *testing.T) (http.Handler, config.Config) {
t.Helper()
router, cfg, _ := newTestRouterWithStore(t)
return router, cfg
}
func newTestRouterWithStore(t *testing.T) (http.Handler, config.Config, *db.Store) {
return newTestRouterWithConfig(t, nil)
}
func newTestRouterWithConfig(t *testing.T, mutate func(*config.Config)) (http.Handler, config.Config, *db.Store) {
t.Helper()
store := newHTTPTestStore(t)
t.Cleanup(func() {
_ = store.Close()
})
artifactsDir := filepath.Join(t.TempDir(), "artifacts")
cfg := config.Config{
AppName: "Update Server",
BaseURL: "http://127.0.0.1:8080",
ArtifactsDir: artifactsDir,
TemplatesDir: httpProjectPath(t, "web", "templates"),
StaticDir: httpProjectPath(t, "web", "static"),
AdminEmail: "admin@example.com",
AdminPassword: "correct horse battery staple",
MaxUploadBytes: 8 << 20,
SessionCookieName: "update_server_session",
CSRFCookieName: testCSRFCookieName,
SessionTTL: 24 * time.Hour,
ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
ShutdownTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
LoginRateLimitPerMinute: 10,
LoginRateLimitBurst: 5,
ClientRateLimitPerMinute: 120,
ClientRateLimitBurst: 60,
}
if mutate != nil {
mutate(&cfg)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
renderer, err := httpserver.NewRenderer(cfg.TemplatesDir)
if err != nil {
t.Fatalf("create renderer: %v", err)
}
authService := auth.NewService(cfg, logger, store)
apiKeyService := apikeys.NewService(store)
if err := authService.EnsureBootstrapAdmin(context.Background()); err != nil {
t.Fatalf("bootstrap admin: %v", err)
}
artifactStore, err := storage.NewLocal(cfg.ArtifactsDir)
if err != nil {
t.Fatalf("create artifact storage: %v", err)
}
releaseService := releases.NewService(store, artifactStore)
return httpserver.NewRouter(cfg, logger, renderer, store, authService, apiKeyService, releaseService), cfg, store
}
func newHTTPTestStore(t *testing.T) *db.Store {
t.Helper()
sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite")
database, err := db.Open(context.Background(), sqlitePath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Migrate(context.Background(), database, httpProjectPath(t, "migrations")); err != nil {
_ = database.Close()
t.Fatalf("migrate sqlite: %v", err)
}
return db.NewStore(database)
}
func httpProjectPath(t *testing.T, parts ...string) string {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve caller path")
}
root := filepath.Join(filepath.Dir(filename), "..", "..")
items := append([]string{root}, parts...)
return filepath.Join(items...)
}
func performRequest(t *testing.T, handler http.Handler, method, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req := httptest.NewRequest(method, target, body)
req.RemoteAddr = "127.0.0.1:12345"
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
for _, cookie := range cookies {
req.AddCookie(cookie)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
return recorder
}
func ensureCSRFCookie(t *testing.T, handler http.Handler, cookies ...*http.Cookie) *http.Cookie {
t.Helper()
for _, cookie := range cookies {
if cookie != nil && cookie.Name == testCSRFCookieName && cookie.Value != "" {
return cookie
}
}
recorder := performRequest(t, handler, http.MethodGet, "/admin/login", nil, cookies...)
if recorder.Code != http.StatusOK && recorder.Code != http.StatusSeeOther {
t.Fatalf("expected csrf bootstrap request to succeed, got %d with body %s", recorder.Code, recorder.Body.String())
}
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == testCSRFCookieName && cookie.Value != "" {
return cookie
}
}
t.Fatal("expected csrf cookie from admin bootstrap request")
return nil
}