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

568 lines
26 KiB
Go

package web
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/backup"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/importexport"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
)
type webLifecycleAgent struct{}
func (webLifecycleAgent) CreateInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "container-1", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
}
func (webLifecycleAgent) InspectInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Health: "stopped"}, nil
}
func (webLifecycleAgent) StartInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Running: true, Ready: true, Health: "healthy"}, nil
}
func (webLifecycleAgent) StopInstance(_ context.Context, id string, _ int) (agentwire.InstanceState, error) {
return agentwire.InstanceState{InstanceID: id, ContainerID: "container-1", Health: "stopped"}, nil
}
func (webLifecycleAgent) RestartInstance(ctx context.Context, id string, _ int) (agentwire.InstanceState, error) {
return webLifecycleAgent{}.StartInstance(ctx, id)
}
func (webLifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
func (webLifecycleAgent) GetInstanceStats(_ context.Context, id string) (agentwire.InstanceStats, error) {
return agentwire.InstanceStats{InstanceID: id}, nil
}
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)
}
icon := request(t, handler, http.MethodGet, "/public/game-icons/palworld", nil)
assertStatus(t, icon, http.StatusOK)
if icon.Header().Get("Content-Type") != "image/png" {
t.Fatalf("icon content type = %q", icon.Header().Get("Content-Type"))
}
traversal := request(t, handler, http.MethodGet, "/public/game-icons/..%2Fprivate", nil)
if traversal.Code == http.StatusOK {
t.Fatal("icon traversal accepted")
}
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 draftResponse map[string]string
if err := json.Unmarshal(draft.Body.Bytes(), &draftResponse); err != nil {
t.Fatal(err)
}
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)
}
lifecycleHandler, err := NewHandlerWithLifecycle(authService, repository, webLifecycleAgent{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
installPath := "/api/v1/instances/" + draftResponse["id"] + "/install"
deniedInstall := jsonRequest(t, lifecycleHandler, installPath, nil, sessionCookieValue, "")
assertStatus(t, deniedInstall, http.StatusForbidden)
install := jsonRequest(t, lifecycleHandler, installPath, nil, sessionCookieValue, session.CSRFToken)
assertStatus(t, install, http.StatusOK)
unsafeDelete := httptest.NewRequest(http.MethodDelete, "/api/v1/instances/"+draftResponse["id"], strings.NewReader(`{"scope":"player_data"}`))
unsafeDelete.AddCookie(sessionCookieValue)
unsafeDelete.Header.Set("X-CSRF-Token", session.CSRFToken)
unsafeResponse := httptest.NewRecorder()
lifecycleHandler.ServeHTTP(unsafeResponse, unsafeDelete)
assertStatus(t, unsafeResponse, http.StatusUnprocessableEntity)
}
func TestBackupAPIEnforcesPermissionsAndRestores(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
serversRoot := filepath.Join(root, "servers")
backupsRoot := filepath.Join(root, "backups")
mount := filepath.Join(serversRoot, "instance", "saved")
if err := os.MkdirAll(mount, 0o750); err != nil {
t.Fatal(err)
}
world := filepath.Join(mount, "Level.sav")
if err := os.WriteFile(world, []byte("world-v1"), 0o640); err != nil {
t.Fatal(err)
}
db, err := sqlite.Open(ctx, filepath.Join(root, "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)
}
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
admin, err := authService.Authenticate(ctx, adminSession.Token)
if err != nil {
t.Fatal(err)
}
player, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
}
playerSession, err := authService.Login(ctx, "player", "another correct battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Backup API", Slug: "backup-api", HostPorts: map[string]int{"game": 38211}, MountPaths: map[string]string{"saved": mount}, DataOrigin: "new", BackupRetention: 2})
if err != nil {
t.Fatal(err)
}
const instanceID = "backup-api-instance"
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE instances SET lifecycle_state='online', observed_state='ready', container_id='container', desired_running=1 WHERE id=?`, instanceID); err != nil {
t.Fatal(err)
}
backupService, err := backup.New(repository, webLifecycleAgent{}, serversRoot, backupsRoot)
if err != nil {
t.Fatal(err)
}
importService, err := importexport.New(repository, filepath.Join(root, "imports"), serversRoot)
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerWithLifecycleAndBackup(authService, repository, webLifecycleAgent{}, backupService, importService, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
adminCookie := &http.Cookie{Name: sessionCookie, Value: adminSession.Token}
playerCookie := &http.Cookie{Name: sessionCookie, Value: playerSession.Token}
denied := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups", []*http.Cookie{playerCookie})
assertStatus(t, denied, http.StatusForbidden)
membership := jsonMethodRequest(t, handler, http.MethodPut, "/api/v1/instances/"+instanceID+"/memberships/"+player.ID, []byte(`{"role":"manager"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, membership, http.StatusNoContent)
created := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/backups", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, created, http.StatusCreated)
var value backup.Backup
if err := json.Unmarshal(created.Body.Bytes(), &value); err != nil {
t.Fatal(err)
}
listed := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups", []*http.Cookie{playerCookie})
assertStatus(t, listed, http.StatusOK)
exportDenied := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups/"+value.ID+"/export", []*http.Cookie{playerCookie})
assertStatus(t, exportDenied, http.StatusForbidden)
exported := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID+"/backups/"+value.ID+"/export", []*http.Cookie{adminCookie})
assertStatus(t, exported, http.StatusOK)
if exported.Header().Get("X-Content-SHA256") == "" {
t.Fatal("export checksum header missing")
}
if err := os.WriteFile(world, []byte("world-v2"), 0o640); err != nil {
t.Fatal(err)
}
restored := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/backups/"+value.ID+"/restore", nil, adminCookie, adminSession.CSRFToken)
assertStatus(t, restored, http.StatusOK)
body, err := os.ReadFile(world)
if err != nil || string(body) != "world-v1" {
t.Fatalf("restored world=%q error=%v", body, err)
}
policy := jsonMethodRequest(t, handler, http.MethodPut, "/api/v1/instances/"+instanceID+"/backup-policy", []byte(`{"enabled":true,"cron_expression":"0 3 * * *","timezone":"Europe/Paris","retention_count":5}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, policy, http.StatusOK)
imported, err := importService.Stage(ctx, admin.ID, "zip", bytes.NewReader(palworldImportZIP(t)), importexport.Policy{TemplateID: snapshots[0].Template.ID, TemplateVersion: snapshots[0].Template.Version, AcceptedFormats: snapshots[0].Template.Imports.AcceptedFormats, MaxExpandedBytes: int64(snapshots[0].Template.Imports.MaxExtractedSizeGB) << 30, RequiredPaths: snapshots[0].Template.Imports.RequiredPaths})
if err != nil {
t.Fatal(err)
}
importMount := filepath.Join(serversRoot, "imported", "saved")
importPreview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{DisplayName: "Imported API", Slug: "imported-api", HostPorts: map[string]int{"game": 38212}, MountPaths: map[string]string{"saved": importMount}, DataOrigin: "import", ImportID: imported.ID, BackupRetention: 2})
if err != nil {
t.Fatal(err)
}
const importedInstanceID = "imported-api-instance"
if err := repository.CreateDraft(ctx, instance.Draft{ID: importedInstanceID, Preview: importPreview}); err != nil {
t.Fatal(err)
}
installed := jsonRequest(t, handler, "/api/v1/instances/"+importedInstanceID+"/install", nil, adminCookie, adminSession.CSRFToken)
assertStatus(t, installed, http.StatusOK)
importedWorld := filepath.Join(importMount, "SaveGames", "0", "Level.sav")
if body, err := os.ReadFile(importedWorld); err != nil || string(body) != "imported-world" {
t.Fatalf("imported world=%q error=%v", body, err)
}
}
func palworldImportZIP(t *testing.T) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, body := range map[string]string{"Save/Level.sav": "imported-world", "Save/Players/player.sav": "player"} {
entry, err := writer.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(entry, body); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
return buffer.Bytes()
}
func TestInstanceAuthorizationAndInstallationRequestWorkflow(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)
}
player, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user")
if err != nil {
t.Fatal(err)
}
adminSession, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
playerSession, err := authService.Login(ctx, "player", "another correct battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
handler, err := NewHandlerWithLifecycle(authService, repository, webLifecycleAgent{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
adminCookie := &http.Cookie{Name: sessionCookie, Value: adminSession.Token}
playerCookie := &http.Cookie{Name: sessionCookie, Value: playerSession.Token}
draftPayload, _ := json.Marshal(map[string]any{
"template_id": "palworld-official", "template_version": "1.0.0",
"display_name": "Authorization Test", "slug": "authorization-test",
"host_ports": map[string]int{"game": 8211},
"mount_paths": map[string]string{"saved": "/srv/game-servers/authorization-test/saved"},
"data_origin": "new", "backup_retention": 7,
})
draft := jsonRequest(t, handler, "/api/v1/instances/drafts", draftPayload, adminCookie, adminSession.CSRFToken)
assertStatus(t, draft, http.StatusCreated)
var draftResult map[string]string
if err := json.Unmarshal(draft.Body.Bytes(), &draftResult); err != nil {
t.Fatal(err)
}
instanceID := draftResult["id"]
install := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/install", nil, adminCookie, adminSession.CSRFToken)
assertStatus(t, install, http.StatusOK)
denied := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID, []*http.Cookie{playerCookie})
assertStatus(t, denied, http.StatusForbidden)
membershipPath := "/api/v1/instances/" + instanceID + "/memberships/" + player.ID
membership := jsonMethodRequest(t, handler, http.MethodPut, membershipPath, []byte(`{"role":"user"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, membership, http.StatusNoContent)
inspect := request(t, handler, http.MethodGet, "/api/v1/instances/"+instanceID, []*http.Cookie{playerCookie})
assertStatus(t, inspect, http.StatusOK)
start := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/start", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, start, http.StatusOK)
restart := jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, restart, http.StatusForbidden)
membership = jsonMethodRequest(t, handler, http.MethodPut, membershipPath, []byte(`{"role":"manager"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, membership, http.StatusNoContent)
restart = jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, restart, http.StatusOK)
overridePath := membershipPath + "/permissions/instance.restart"
override := jsonMethodRequest(t, handler, http.MethodPut, overridePath, []byte(`{"effect":"deny"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, override, http.StatusNoContent)
restart = jsonRequest(t, handler, "/api/v1/instances/"+instanceID+"/restart", nil, playerCookie, playerSession.CSRFToken)
assertStatus(t, restart, http.StatusForbidden)
substitution := request(t, handler, http.MethodGet, "/api/v1/instances/not-the-member-instance", []*http.Cookie{playerCookie})
assertStatus(t, substitution, http.StatusForbidden)
requestPayload := []byte(`{"template_id":"palworld-official","template_version":"1.0.0","suggested_name":"Friends","player_estimate":8,"desired_schedule":"evenings","mods_requested":true,"message":"Private group"}`)
installationRequest := jsonRequest(t, handler, "/api/v1/installation-requests", requestPayload, playerCookie, playerSession.CSRFToken)
assertStatus(t, installationRequest, http.StatusCreated)
var createdRequest struct {
ID string `json:"id"`
}
if err := json.Unmarshal(installationRequest.Body.Bytes(), &createdRequest); err != nil {
t.Fatal(err)
}
duplicate := jsonRequest(t, handler, "/api/v1/installation-requests", requestPayload, playerCookie, playerSession.CSRFToken)
assertStatus(t, duplicate, http.StatusConflict)
playerList := request(t, handler, http.MethodGet, "/api/v1/installation-requests", []*http.Cookie{playerCookie})
assertStatus(t, playerList, http.StatusOK)
if !strings.Contains(playerList.Body.String(), createdRequest.ID) {
t.Fatalf("request list = %s", playerList.Body.String())
}
var instancesBefore int
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instancesBefore); err != nil {
t.Fatal(err)
}
review := jsonRequest(t, handler, "/api/v1/installation-requests/"+createdRequest.ID+"/review", []byte(`{"decision":"approved","reason":"capacity available"}`), adminCookie, adminSession.CSRFToken)
assertStatus(t, review, http.StatusOK)
var instancesAfter int
if err := db.QueryRow("SELECT COUNT(*) FROM instances").Scan(&instancesAfter); err != nil {
t.Fatal(err)
}
if instancesAfter != instancesBefore {
t.Fatalf("approval deployed an instance: before=%d after=%d", instancesBefore, instancesAfter)
}
}
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 {
return jsonMethodRequest(t, handler, http.MethodPost, target, body, session, csrf)
}
func jsonMethodRequest(t *testing.T, handler http.Handler, method, target string, body []byte, session *http.Cookie, csrf string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, 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())
}
}