269 lines
8 KiB
Go
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
|
|
}
|