510 lines
24 KiB
Go
510 lines
24 KiB
Go
package web
|
|
|
|
import (
|
|
"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/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)
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
handler, err := NewHandlerWithLifecycleAndBackup(authService, repository, webLifecycleAgent{}, backupService, nil, 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)
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|