init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
269
internal/http/auth_integration_test.go
Normal file
269
internal/http/auth_integration_test.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue