Files
DoGaMa-serv/internal/web/server_test.go
T

257 lines
11 KiB
Go

package web
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
func TestCatalogPreviewAndDraftAPIAuthorization(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository := sqlite.NewRepository(db)
snapshots, err := catalog.LoadFS(catalogdata.Files, ".")
if err != nil {
t.Fatal(err)
}
if err := repository.Sync(ctx, snapshots); err != nil {
t.Fatal(err)
}
authService := auth.New(db)
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
session, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerWithRepository(authService, repository, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
unauthenticated := request(t, handler, http.MethodGet, "/api/v1/catalog", nil)
assertStatus(t, unauthenticated, http.StatusUnauthorized)
sessionCookieValue := &http.Cookie{Name: sessionCookie, Value: session.Token}
catalogResponse := request(t, handler, http.MethodGet, "/api/v1/catalog", []*http.Cookie{sessionCookieValue})
assertStatus(t, catalogResponse, http.StatusOK)
if !strings.Contains(catalogResponse.Body.String(), "palworld-official") {
t.Fatalf("catalog response = %s", catalogResponse.Body.String())
}
payload, _ := json.Marshal(map[string]any{
"template_id": "palworld-official", "template_version": "1.0.0",
"display_name": "Family Palworld", "slug": "family-palworld",
"host_ports": map[string]int{"game": 8211},
"mount_paths": map[string]string{"saved": "/srv/game-servers/family-palworld/saved"},
"data_origin": "new", "backup_retention": 7,
})
denied := jsonRequest(t, handler, "/api/v1/instances/preview", payload, sessionCookieValue, "")
assertStatus(t, denied, http.StatusForbidden)
preview := jsonRequest(t, handler, "/api/v1/instances/preview", payload, sessionCookieValue, session.CSRFToken)
assertStatus(t, preview, http.StatusOK)
if !strings.Contains(preview.Body.String(), snapshots[0].Digest) {
t.Fatalf("preview response = %s", preview.Body.String())
}
trailingJSON := jsonRequest(t, handler, "/api/v1/instances/preview", append(payload, []byte("{}")...), sessionCookieValue, session.CSRFToken)
assertStatus(t, trailingJSON, http.StatusBadRequest)
draft := jsonRequest(t, handler, "/api/v1/instances/drafts", payload, sessionCookieValue, session.CSRFToken)
assertStatus(t, draft, http.StatusCreated)
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM instances WHERE lifecycle_state='draft'").Scan(&count); err != nil || count != 1 {
t.Fatalf("draft count = %d, error = %v", count, err)
}
}
func TestBootstrapAuthenticationAndLogoutFlow(t *testing.T) {
handler := testHandler(t)
response := request(t, handler, http.MethodGet, "/", nil)
assertStatus(t, response, http.StatusSeeOther)
if location := response.Header().Get("Location"); location != "/setup" {
t.Fatalf("pre-bootstrap location = %q", location)
}
setupPage := request(t, handler, http.MethodGet, "/setup", nil)
assertStatus(t, setupPage, http.StatusOK)
csrf := namedCookie(t, setupPage, csrfCookie)
setup := formRequest(t, handler, "/setup", url.Values{
"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"},
}, csrf)
assertStatus(t, setup, http.StatusSeeOther)
closedSetup := request(t, handler, http.MethodGet, "/setup", nil)
assertStatus(t, closedSetup, http.StatusSeeOther)
if location := closedSetup.Header().Get("Location"); location != "/login" {
t.Fatalf("post-bootstrap setup location = %q", location)
}
loginPage := request(t, handler, http.MethodGet, "/login", nil)
csrf = namedCookie(t, loginPage, csrfCookie)
badCSRF := formRequest(t, handler, "/login", url.Values{
"csrf_token": {"wrong"}, "username": {"admin"}, "password": {"correct horse battery staple"},
}, csrf)
assertStatus(t, badCSRF, http.StatusForbidden)
login := formRequest(t, handler, "/login", url.Values{
"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"},
}, csrf)
assertStatus(t, login, http.StatusSeeOther)
session := namedCookie(t, login, sessionCookie)
sessionCSRF := namedCookie(t, login, csrfCookie)
for _, cookie := range []*http.Cookie{session, sessionCSRF} {
if !cookie.Secure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("insecure cookie attributes: %#v", cookie)
}
}
home := request(t, handler, http.MethodGet, "/", []*http.Cookie{session, sessionCSRF})
assertStatus(t, home, http.StatusOK)
if !strings.Contains(home.Body.String(), "Signed in as <strong>admin</strong>") {
t.Fatalf("protected page did not identify user: %s", home.Body.String())
}
if home.Header().Get("Content-Security-Policy") == "" || home.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers missing")
}
deniedLogout := formRequest(t, handler, "/logout", url.Values{"csrf_token": {"wrong"}}, session, sessionCSRF)
assertStatus(t, deniedLogout, http.StatusForbidden)
logout := formRequest(t, handler, "/logout", url.Values{"csrf_token": {sessionCSRF.Value}}, session, sessionCSRF)
assertStatus(t, logout, http.StatusSeeOther)
afterLogout := request(t, handler, http.MethodGet, "/", []*http.Cookie{session, sessionCSRF})
assertStatus(t, afterLogout, http.StatusSeeOther)
}
func TestLoginReturnsGenericFailureAndRateLimits(t *testing.T) {
handler := testHandler(t)
setupPage := request(t, handler, http.MethodGet, "/setup", nil)
csrf := namedCookie(t, setupPage, csrfCookie)
setup := formRequest(t, handler, "/setup", url.Values{"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
assertStatus(t, setup, http.StatusSeeOther)
loginPage := request(t, handler, http.MethodGet, "/login", nil)
csrf = namedCookie(t, loginPage, csrfCookie)
for attempt := 0; attempt < 5; attempt++ {
response := formRequest(t, handler, "/login", url.Values{"csrf_token": {csrf.Value}, "username": {"unknown"}, "password": {"incorrect password"}}, csrf)
assertStatus(t, response, http.StatusUnauthorized)
if !strings.Contains(response.Body.String(), "Invalid username or password.") {
t.Fatal("login failure was not generic")
}
}
limited := formRequest(t, handler, "/login", url.Values{"csrf_token": {csrf.Value}, "username": {"unknown"}, "password": {"incorrect password"}}, csrf)
assertStatus(t, limited, http.StatusTooManyRequests)
}
func TestFailedLoginKeepsCurrentSessionAndSuccessfulLoginRotatesIt(t *testing.T) {
handler := testHandler(t)
setupPage := request(t, handler, http.MethodGet, "/setup", nil)
csrf := namedCookie(t, setupPage, csrfCookie)
setup := formRequest(t, handler, "/setup", url.Values{"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
assertStatus(t, setup, http.StatusSeeOther)
loginPage := request(t, handler, http.MethodGet, "/login", nil)
csrf = namedCookie(t, loginPage, csrfCookie)
login := formRequest(t, handler, "/login", url.Values{"csrf_token": {csrf.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, csrf)
assertStatus(t, login, http.StatusSeeOther)
oldSession := namedCookie(t, login, sessionCookie)
oldCSRF := namedCookie(t, login, csrfCookie)
failed := formRequest(t, handler, "/login", url.Values{"csrf_token": {oldCSRF.Value}, "username": {"admin"}, "password": {"wrong password"}}, oldSession, oldCSRF)
assertStatus(t, failed, http.StatusUnauthorized)
stillAuthenticated := request(t, handler, http.MethodGet, "/", []*http.Cookie{oldSession, oldCSRF})
assertStatus(t, stillAuthenticated, http.StatusOK)
rotated := formRequest(t, handler, "/login", url.Values{"csrf_token": {oldCSRF.Value}, "username": {"admin"}, "password": {"correct horse battery staple"}}, oldSession, oldCSRF)
assertStatus(t, rotated, http.StatusSeeOther)
newSession := namedCookie(t, rotated, sessionCookie)
newCSRF := namedCookie(t, rotated, csrfCookie)
if newSession.Value == oldSession.Value || newCSRF.Value == oldCSRF.Value {
t.Fatal("successful login did not rotate session credentials")
}
oldCredentials := request(t, handler, http.MethodGet, "/", []*http.Cookie{oldSession, oldCSRF})
assertStatus(t, oldCredentials, http.StatusSeeOther)
newCredentials := request(t, handler, http.MethodGet, "/", []*http.Cookie{newSession, newCSRF})
assertStatus(t, newCredentials, http.StatusOK)
}
func testHandler(t *testing.T) http.Handler {
t.Helper()
db, err := sqlite.Open(context.Background(), filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
handler, err := NewHandler(auth.New(db), slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
return handler
}
func request(t *testing.T, handler http.Handler, method, target string, cookies []*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, target, nil)
for _, cookie := range cookies {
request.AddCookie(cookie)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func formRequest(t *testing.T, handler http.Handler, target string, values url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, target, strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, cookie := range cookies {
request.AddCookie(cookie)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func jsonRequest(t *testing.T, handler http.Handler, target string, body []byte, session *http.Cookie, csrf string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-CSRF-Token", csrf)
request.AddCookie(session)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func namedCookie(t *testing.T, response *httptest.ResponseRecorder, name string) *http.Cookie {
t.Helper()
for _, cookie := range response.Result().Cookies() {
if cookie.Name == name && cookie.MaxAge >= 0 {
return cookie
}
}
t.Fatalf("cookie %q not found", name)
return nil
}
func assertStatus(t *testing.T, response *httptest.ResponseRecorder, expected int) {
t.Helper()
if response.Code != expected {
t.Fatalf("status = %d, want %d; body = %s", response.Code, expected, response.Body.String())
}
}