1305 lines
62 KiB
Go
1305 lines
62 KiB
Go
package web
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
catalogdata "git.zaynet.fr/DoGaMa/DoGaMa-serv/catalog"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
|
|
"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/notification"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
|
|
)
|
|
|
|
type webLifecycleAgent struct{}
|
|
|
|
type blockingLifecycleAgent struct {
|
|
entered chan struct{}
|
|
release chan struct{}
|
|
panicOnCreate bool
|
|
}
|
|
|
|
func (a *blockingLifecycleAgent) CreateInstance(_ context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
|
|
close(a.entered)
|
|
<-a.release
|
|
if a.panicOnCreate {
|
|
panic("controlled worker panic")
|
|
}
|
|
return agentwire.InstanceState{InstanceID: plan.InstanceID, ContainerID: "controlled-container", PlanDigest: plan.PlanDigest, Health: "stopped"}, nil
|
|
}
|
|
func (a *blockingLifecycleAgent) InspectInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
|
|
return agentwire.InstanceState{InstanceID: id}, nil
|
|
}
|
|
func (a *blockingLifecycleAgent) StartInstance(_ context.Context, id string) (agentwire.InstanceState, error) {
|
|
return agentwire.InstanceState{InstanceID: id, ContainerID: "controlled-container", Running: true, Ready: true, Health: "healthy"}, nil
|
|
}
|
|
func (a *blockingLifecycleAgent) StopInstance(context.Context, string, int) (agentwire.InstanceState, error) {
|
|
return agentwire.InstanceState{}, nil
|
|
}
|
|
func (a *blockingLifecycleAgent) RestartInstance(context.Context, string, int) (agentwire.InstanceState, error) {
|
|
return agentwire.InstanceState{}, nil
|
|
}
|
|
func (a *blockingLifecycleAgent) DeleteContainer(context.Context, string) error { return nil }
|
|
func (a *blockingLifecycleAgent) GetInstanceStats(context.Context, string) (agentwire.InstanceStats, error) {
|
|
return agentwire.InstanceStats{}, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type fakeModuleRuntime struct {
|
|
live instance.Live
|
|
bans []instance.Ban
|
|
actionErr error
|
|
actionCall int
|
|
request map[string]string
|
|
}
|
|
|
|
func (f *fakeModuleRuntime) Live(context.Context, instance.StoredInstance, bool) instance.Live {
|
|
return f.live
|
|
}
|
|
|
|
func (f *fakeModuleRuntime) ListBans(context.Context, instance.StoredInstance) ([]instance.Ban, error) {
|
|
return f.bans, nil
|
|
}
|
|
|
|
func (f *fakeModuleRuntime) Action(_ context.Context, _ instance.StoredInstance, _ string, _ string, request any) error {
|
|
f.actionCall++
|
|
f.request, _ = request.(map[string]string)
|
|
return f.actionErr
|
|
}
|
|
|
|
func TestInstanceUnbanCapabilityFallback(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)
|
|
}
|
|
preview, err := instance.BuildPreview(snapshots[0], instance.PreviewRequest{
|
|
DisplayName: "Unban fixture", HostPorts: map[string]int{"game": 8211},
|
|
MountPaths: map[string]string{"saved": filepath.Join(t.TempDir(), "saved")}, DataOrigin: "new", BackupRetention: 7,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const instanceID = "unban-fixture"
|
|
if err := repository.CreateDraft(ctx, instance.Draft{ID: instanceID, Preview: preview}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
authService := auth.New(db)
|
|
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
admin, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
viewer, err := authService.CreateUser(ctx, "viewer", "another correct battery staple", "user")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
users, err := authService.ListUsers(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var adminID string
|
|
for _, user := range users {
|
|
if user.Username == "admin" {
|
|
adminID = user.ID
|
|
}
|
|
}
|
|
if err := repository.SetMembership(ctx, adminID, instanceID, viewer.ID, "user"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
viewerSession, err := authService.Login(ctx, "viewer", "another correct battery staple", "192.0.2.2:1234")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
newHandler := func(runtime *fakeModuleRuntime) http.Handler {
|
|
handler, err := NewHandlerCompleteWithCatalogDeploymentAndRuntime(authService, repository, nil, nil, nil, audit.New(db), nil, nil, t.TempDir(), runtime, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return handler
|
|
}
|
|
adminCookies := []*http.Cookie{{Name: sessionCookie, Value: admin.Token}, {Name: csrfCookie, Value: admin.CSRFToken}}
|
|
|
|
t.Run("unban absent hides action", func(t *testing.T) {
|
|
handler := newHandler(&fakeModuleRuntime{live: instance.Live{Capabilities: map[string]bool{}}})
|
|
page := request(t, handler, http.MethodGet, "/instances/"+instanceID, adminCookies)
|
|
assertStatus(t, page, http.StatusOK)
|
|
if strings.Contains(page.Body.String(), "/module/unban") {
|
|
t.Fatal("unban action rendered without unban capability")
|
|
}
|
|
})
|
|
|
|
t.Run("unavailable runtime hides manual action", func(t *testing.T) {
|
|
handler := newHandler(&fakeModuleRuntime{live: instance.Live{Unavailable: true}})
|
|
page := request(t, handler, http.MethodGet, "/instances/"+instanceID, adminCookies)
|
|
assertStatus(t, page, http.StatusOK)
|
|
if strings.Contains(page.Body.String(), "/module/unban") {
|
|
t.Fatal("unban action rendered while runtime was unavailable")
|
|
}
|
|
})
|
|
|
|
t.Run("list bans keeps selection validation", func(t *testing.T) {
|
|
runtime := &fakeModuleRuntime{live: instance.Live{Capabilities: map[string]bool{"unban": true, "list_bans": true}, BansChecked: true, Bans: []instance.Ban{{PlayerID: "real-id", DisplayName: "Real player"}}}, bans: []instance.Ban{{PlayerID: "real-id", DisplayName: "Real player"}}}
|
|
handler := newHandler(runtime)
|
|
page := request(t, handler, http.MethodGet, "/instances/"+instanceID, adminCookies)
|
|
if !strings.Contains(page.Body.String(), "<select name=\"player_id\"") || strings.Contains(page.Body.String(), "Player identifier") {
|
|
t.Fatal("ban selection did not retain list-only UI")
|
|
}
|
|
invalid := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"csrf_token": {admin.CSRFToken}, "player_id": {"invented-id"}}, adminCookies...)
|
|
assertStatus(t, invalid, http.StatusSeeOther)
|
|
if runtime.actionCall != 0 {
|
|
t.Fatal("unlisted player was sent to module")
|
|
}
|
|
valid := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"csrf_token": {admin.CSRFToken}, "player_id": {"real-id"}}, adminCookies...)
|
|
assertStatus(t, valid, http.StatusSeeOther)
|
|
if runtime.actionCall != 1 || runtime.request["player_id"] != "real-id" {
|
|
t.Fatalf("listed player action = %#v, calls=%d", runtime.request, runtime.actionCall)
|
|
}
|
|
})
|
|
|
|
t.Run("palworld manual fallback validates and audits", func(t *testing.T) {
|
|
manifest, err := os.ReadFile("../../catalog/palworld/module/manifest.yaml")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(manifest), " - unban") || strings.Contains(string(manifest), "list_bans") {
|
|
t.Fatal("Palworld fixture no longer represents unban without list_bans")
|
|
}
|
|
runtime := &fakeModuleRuntime{live: instance.Live{Capabilities: map[string]bool{"unban": true}}}
|
|
handler := newHandler(runtime)
|
|
page := request(t, handler, http.MethodGet, "/instances/"+instanceID, adminCookies)
|
|
if !strings.Contains(page.Body.String(), "Player identifier") || strings.Contains(page.Body.String(), "<select name=\"player_id\"") {
|
|
t.Fatal("manual unban fallback was not rendered")
|
|
}
|
|
for _, playerID := range []string{"", strings.Repeat("a", 257), "eos\x1fuser"} {
|
|
response := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"csrf_token": {admin.CSRFToken}, "player_id": {playerID}}, adminCookies...)
|
|
assertStatus(t, response, http.StatusSeeOther)
|
|
}
|
|
if runtime.actionCall != 0 {
|
|
t.Fatal("invalid manual identifier reached module")
|
|
}
|
|
csrfDenied := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"player_id": {"eos-user"}}, adminCookies...)
|
|
assertStatus(t, csrfDenied, http.StatusForbidden)
|
|
viewerDenied := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"csrf_token": {viewerSession.CSRFToken}, "player_id": {"eos-user"}}, &http.Cookie{Name: sessionCookie, Value: viewerSession.Token}, &http.Cookie{Name: csrfCookie, Value: viewerSession.CSRFToken})
|
|
assertStatus(t, viewerDenied, http.StatusSeeOther)
|
|
valid := formRequest(t, handler, "/instances/"+instanceID+"/module/unban", url.Values{"csrf_token": {admin.CSRFToken}, "player_id": {" eos-user "}}, adminCookies...)
|
|
assertStatus(t, valid, http.StatusSeeOther)
|
|
if runtime.actionCall != 1 || runtime.request["player_id"] != "eos-user" {
|
|
t.Fatalf("manual unban action = %#v, calls=%d", runtime.request, runtime.actionCall)
|
|
}
|
|
events, err := audit.New(db).List(ctx, audit.Filter{Action: "players.unban", InstanceID: instanceID})
|
|
if err != nil || len(events) == 0 || events[0].Outcome != "allowed" {
|
|
t.Fatalf("unban audit events = %#v, err=%v", events, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
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"))
|
|
}
|
|
artwork := request(t, handler, http.MethodGet, "/public/game-artwork/palworld", nil)
|
|
assertStatus(t, artwork, http.StatusOK)
|
|
if artwork.Header().Get("Content-Type") != "image/jpeg" || artwork.Body.Len() < 100000 {
|
|
t.Fatalf("artwork response: type=%q size=%d", artwork.Header().Get("Content-Type"), artwork.Body.Len())
|
|
}
|
|
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": snapshots[0].Template.ID, "template_version": snapshots[0].Template.Version,
|
|
"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 TestNotificationAndAuditAdministration(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)
|
|
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)
|
|
}
|
|
auditService := audit.New(db)
|
|
notificationService, err := notification.New(db, bytes.Repeat([]byte{3}, 32))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
handler, err := NewHandlerComplete(authService, repository, nil, nil, nil, auditService, notificationService, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cookie := &http.Cookie{Name: sessionCookie, Value: session.Token}
|
|
payload, _ := json.Marshal(map[string]any{"name": "operations", "type": "webhook", "enabled": true, "events": []string{"backup.failed"}, "config": map[string]string{"url": "https://example.com/hook", "signing_secret": "do-not-return"}})
|
|
created := jsonMethodRequest(t, handler, http.MethodPost, "/api/v1/admin/notification-channels", payload, cookie, session.CSRFToken)
|
|
assertStatus(t, created, http.StatusCreated)
|
|
if strings.Contains(created.Body.String(), "do-not-return") {
|
|
t.Fatal("channel secret returned")
|
|
}
|
|
listed := request(t, handler, http.MethodGet, "/api/v1/admin/notification-channels", []*http.Cookie{cookie})
|
|
assertStatus(t, listed, http.StatusOK)
|
|
if strings.Contains(listed.Body.String(), "do-not-return") || !strings.Contains(listed.Body.String(), "operations") {
|
|
t.Fatalf("unsafe channel response: %s", listed.Body.String())
|
|
}
|
|
auditResponse := request(t, handler, http.MethodGet, "/api/v1/admin/audit", []*http.Cookie{cookie})
|
|
assertStatus(t, auditResponse, http.StatusOK)
|
|
if !strings.Contains(auditResponse.Body.String(), "notification.channel.update") {
|
|
t.Fatalf("missing audit event: %s", auditResponse.Body.String())
|
|
}
|
|
home := request(t, handler, http.MethodGet, "/", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
|
|
assertStatus(t, home, http.StatusOK)
|
|
if !strings.Contains(home.Body.String(), "Game servers") || strings.Contains(home.Body.String(), "Notification channels") {
|
|
t.Fatal("dashboard was not separated from administration settings")
|
|
}
|
|
settings := request(t, handler, http.MethodGet, "/administration", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
|
|
assertStatus(t, settings, http.StatusOK)
|
|
settingsBody := settings.Body.String()
|
|
for _, expected := range []string{
|
|
"Notification channels", "Web access", "href=\"#audit\"", "href=\"/audit\"",
|
|
"id=\"notifications\"", "id=\"notification-general\"", "id=\"email\"", "id=\"discord\"", "id=\"gotify\"", "id=\"game-runtime\"", "name=\"uid\"", "name=\"gid\"",
|
|
} {
|
|
if !strings.Contains(settingsBody, expected) {
|
|
t.Fatalf("settings UI section %q missing", expected)
|
|
}
|
|
}
|
|
runtimeSave := formRequest(t, handler, "/admin/game-container-runtime", url.Values{"csrf_token": {session.CSRFToken}, "uid": {"1234"}, "gid": {"5678"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
|
|
assertStatus(t, runtimeSave, http.StatusSeeOther)
|
|
identity, err := repository.GetGameContainerRuntimeIdentity(ctx)
|
|
if err != nil || identity != (instance.RuntimeIdentity{UID: 1234, GID: 5678}) {
|
|
t.Fatalf("runtime identity = %#v error=%v", identity, err)
|
|
}
|
|
invalidRuntime := formRequest(t, handler, "/admin/game-container-runtime", url.Values{"csrf_token": {session.CSRFToken}, "uid": {"1000:1000"}, "gid": {"5678"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
|
|
assertStatus(t, invalidRuntime, http.StatusUnprocessableEntity)
|
|
notificationLanguage := formRequest(t, handler, "/admin/notification-language", url.Values{"csrf_token": {session.CSRFToken}, "language": {"fr"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
|
|
assertStatus(t, notificationLanguage, http.StatusSeeOther)
|
|
if got := notificationLanguage.Result().Header.Get("Location"); got != "/administration#notifications" {
|
|
t.Fatalf("notification language redirect = %q", got)
|
|
}
|
|
usersPage := request(t, handler, http.MethodGet, "/administration/users", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
|
|
assertStatus(t, usersPage, http.StatusOK)
|
|
if !strings.Contains(usersPage.Body.String(), "Create user") || !strings.Contains(usersPage.Body.String(), "admin") {
|
|
t.Fatal("user administration UI missing")
|
|
}
|
|
createdUser := jsonMethodRequest(t, handler, http.MethodPost, "/api/v1/admin/users", []byte(`{"username":"operator","email":"operator@example.test","password":"another correct battery staple","role":"admin","disabled":false}`), cookie, session.CSRFToken)
|
|
assertStatus(t, createdUser, http.StatusCreated)
|
|
var operator auth.User
|
|
if err := json.Unmarshal(createdUser.Body.Bytes(), &operator); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
adminUsers, err := authService.ListUsers(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var adminID string
|
|
for _, current := range adminUsers {
|
|
if current.Username == "admin" {
|
|
adminID = current.ID
|
|
}
|
|
}
|
|
updated := jsonMethodRequest(t, handler, http.MethodPut, "/api/v1/admin/users/"+operator.ID, []byte(`{"email":"operator@example.test","role":"user","disabled":true}`), cookie, session.CSRFToken)
|
|
assertStatus(t, updated, http.StatusNoContent)
|
|
lastAdmin := jsonMethodRequest(t, handler, http.MethodPut, "/api/v1/admin/users/"+adminID, []byte(`{"email":"admin@local.invalid","role":"user","disabled":false}`), cookie, session.CSRFToken)
|
|
assertStatus(t, lastAdmin, http.StatusConflict)
|
|
auditPage := request(t, handler, http.MethodGet, "/audit", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
|
|
assertStatus(t, auditPage, http.StatusOK)
|
|
if !strings.Contains(auditPage.Body.String(), "audit-filter-grid") || !strings.Contains(auditPage.Body.String(), "audit-pagination") && !strings.Contains(auditPage.Body.String(), "Heures affichées en") && !strings.Contains(auditPage.Body.String(), "Times shown in") {
|
|
t.Fatal("audit UI missing")
|
|
}
|
|
}
|
|
|
|
func TestDashboardRendersRealSummarySearchAndServerActivity(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)
|
|
}
|
|
if err := repository.CreateDraft(ctx, instance.Draft{ID: "palworld-main", Preview: instance.Preview{DisplayName: "Main server", Slug: "main-server", Template: instance.TemplateReference{ID: snapshots[0].Template.ID, Version: snapshots[0].Template.Version, Digest: snapshots[0].Digest}, Game: instance.GameReference{Name: "Palworld"}, DockerUser: instance.DockerUser{Mode: "dogama"}, ImageTag: instance.ImageTag{Mode: "tracked"}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Exec(`UPDATE instances SET lifecycle_state='online', observed_state='ready' WHERE id='palworld-main'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
auditService := audit.New(db)
|
|
if err := auditService.Record(ctx, audit.Event{InstanceID: "palworld-main", ActorLabel: "admin", Action: "instance.start", Outcome: "allowed", OccurredAt: time.Now().UTC(), Summary: map[string]string{"game_name": "Palworld", "instance_name": "Main server", "instance_slug": "main-server", "target_id": "palworld-main"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := auditService.Record(ctx, audit.Event{ActorLabel: "admin", Action: "auth.login", Outcome: "allowed", OccurredAt: time.Now().UTC().Add(time.Minute)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
handler, err := NewHandlerComplete(authService, repository, nil, nil, nil, auditService, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if 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)
|
|
}
|
|
home := request(t, handler, http.MethodGet, "/", []*http.Cookie{{Name: sessionCookie, Value: session.Token}, {Name: csrfCookie, Value: session.CSRFToken}})
|
|
assertStatus(t, home, http.StatusOK)
|
|
body := home.Body.String()
|
|
for _, expected := range []string{"instance-search", "Total instances", "Updating", "system-heading", "Palworld", "instance start", "dogama.png"} {
|
|
if !strings.Contains(body, expected) {
|
|
t.Fatalf("dashboard missing %q", expected)
|
|
}
|
|
}
|
|
if strings.Contains(body, "auth.login") {
|
|
t.Fatal("authentication event leaked into recent activity")
|
|
}
|
|
if _, err := db.Exec(`DELETE FROM instances WHERE id='palworld-main'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
auditPage := request(t, handler, http.MethodGet, "/audit", []*http.Cookie{{Name: sessionCookie, Value: session.Token}, {Name: csrfCookie, Value: session.CSRFToken}})
|
|
assertStatus(t, auditPage, http.StatusOK)
|
|
for _, expected := range []string{"Palworld", "Main server", "palworld-main"} {
|
|
if !strings.Contains(auditPage.Body.String(), expected) {
|
|
t.Fatalf("deleted instance audit missing %q", expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplicationNavigationHidesAdministrationFromRegularUsers(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)
|
|
authService := auth.New(db)
|
|
if err := authService.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user, err := authService.CreateUser(ctx, "player", "correct horse battery staple", "user")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err := authService.Login(ctx, user.Username, "correct horse battery staple", "192.0.2.2: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)
|
|
}
|
|
cookies := []*http.Cookie{{Name: sessionCookie, Value: session.Token}, {Name: csrfCookie, Value: session.CSRFToken}}
|
|
home := request(t, handler, http.MethodGet, "/", cookies)
|
|
assertStatus(t, home, http.StatusOK)
|
|
if strings.Contains(home.Body.String(), "nav-instances") || strings.Contains(home.Body.String(), "nav-backups") || !strings.Contains(home.Body.String(), "href=\"/account\"") || !strings.Contains(home.Body.String(), "class=\"mobile-header\"") || !strings.Contains(home.Body.String(), "aria-controls=\"app-navigation\"") {
|
|
t.Fatal("account navigation or removed global navigation is incorrect")
|
|
}
|
|
if strings.Contains(home.Body.String(), "href=\"/audit\"") || strings.Contains(home.Body.String(), "href=\"/administration\"") {
|
|
t.Fatal("regular user received administrator navigation")
|
|
}
|
|
assertStatus(t, request(t, handler, http.MethodGet, "/audit", cookies), http.StatusForbidden)
|
|
assertStatus(t, request(t, handler, http.MethodGet, "/administration", cookies), http.StatusForbidden)
|
|
assertStatus(t, request(t, handler, http.MethodGet, "/administration/users", cookies), http.StatusForbidden)
|
|
deniedUpdate := jsonMethodRequest(t, handler, http.MethodPut, "/api/v1/admin/users/"+user.ID, []byte(`{"email":"player@local.invalid","role":"admin","disabled":false}`), cookies[0], session.CSRFToken)
|
|
assertStatus(t, deniedUpdate, http.StatusForbidden)
|
|
}
|
|
|
|
func TestOfficialLogoIsServed(t *testing.T) {
|
|
db, err := sqlite.Open(context.Background(), filepath.Join(t.TempDir(), "dogama.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
handler, err := NewHandler(auth.New(db), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
response := request(t, handler, http.MethodGet, "/static/dogama-logo.png", nil)
|
|
assertStatus(t, response, http.StatusOK)
|
|
if response.Header().Get("Content-Type") != "image/png" || response.Body.Len() < 1000 {
|
|
t.Fatal("official logo asset missing")
|
|
}
|
|
javascript := request(t, handler, http.MethodGet, "/static/app.js", nil)
|
|
assertStatus(t, javascript, http.StatusOK)
|
|
if !strings.Contains(javascript.Body.String(), "nav-open") || !strings.Contains(javascript.Body.String(), "instance-search") {
|
|
t.Fatal("responsive navigation or instance search script missing")
|
|
}
|
|
assertStatus(t, request(t, handler, http.MethodGet, "/static/app.css", nil), http.StatusOK)
|
|
assertStatus(t, request(t, handler, http.MethodGet, "/static/app.js", nil), http.StatusOK)
|
|
assertStatus(t, request(t, handler, http.MethodGet, "/static/theme.css", nil), http.StatusOK)
|
|
theme := request(t, handler, http.MethodGet, "/static/theme.css", nil)
|
|
assertStatus(t, theme, http.StatusOK)
|
|
if !strings.Contains(theme.Body.String(), "dogama-logo.png") {
|
|
t.Fatal("brand-derived application background missing")
|
|
}
|
|
}
|
|
|
|
func TestInstanceStatusPresentationUsesExistingLifecycleStates(t *testing.T) {
|
|
tests := map[string]string{
|
|
"online": "running",
|
|
"stopped": "stopped",
|
|
"installing": "warning",
|
|
"backup": "warning",
|
|
"restore": "warning",
|
|
"update": "warning",
|
|
"intervention_required": "error",
|
|
"unknown": "unknown",
|
|
}
|
|
for state, expected := range tests {
|
|
if actual := statusClass(state); actual != expected {
|
|
t.Errorf("statusClass(%q) = %q, want %q", state, actual, expected)
|
|
}
|
|
if label := statusLabel(state); label == "" {
|
|
t.Errorf("statusLabel(%q) is empty", state)
|
|
}
|
|
}
|
|
}
|
|
|
|
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": snapshots[0].Template.ID, "template_version": snapshots[0].Template.Version,
|
|
"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, err := json.Marshal(map[string]any{"template_id": snapshots[0].Template.ID, "template_version": snapshots[0].Template.Version, "suggested_name": "Friends", "player_estimate": 8, "desired_schedule": "evenings", "mods_requested": true, "message": "Private group"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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"}, "email": {"admin@example.test"}, "password": {"correct horse battery staple"}, "language": {"en"},
|
|
}, 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)
|
|
if strings.Contains(loginPage.Body.String(), "name=\"language\"") || strings.Contains(loginPage.Body.String(), "action=\"/language\"") {
|
|
t.Fatal("login page still exposes a language selector")
|
|
}
|
|
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" || home.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" {
|
|
t.Fatal("security headers missing")
|
|
}
|
|
account := request(t, handler, http.MethodGet, "/account", []*http.Cookie{session, sessionCSRF})
|
|
assertStatus(t, account, http.StatusOK)
|
|
if !strings.Contains(account.Body.String(), `action="/account/email"`) || !strings.Contains(account.Body.String(), `action="/account/password"`) || !strings.Contains(account.Body.String(), `action="/account/language"`) {
|
|
t.Fatal("account settings forms missing")
|
|
}
|
|
badAccountCSRF := formRequest(t, handler, "/account/email", url.Values{"csrf_token": {"wrong"}, "email": {"new@example.test"}}, session, sessionCSRF)
|
|
assertStatus(t, badAccountCSRF, http.StatusForbidden)
|
|
updatedEmail := formRequest(t, handler, "/account/email", url.Values{"csrf_token": {sessionCSRF.Value}, "email": {"new@example.test"}}, session, sessionCSRF)
|
|
assertStatus(t, updatedEmail, http.StatusSeeOther)
|
|
|
|
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"}, "email": {"admin@example.test"}, "password": {"correct horse battery staple"}, "language": {"en"}}, 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"}, "email": {"admin@example.test"}, "password": {"correct horse battery staple"}, "language": {"en"}}, 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())
|
|
}
|
|
}
|
|
|
|
func snapshotByID(t *testing.T, snapshots []catalog.Snapshot, id string) catalog.Snapshot {
|
|
t.Helper()
|
|
|
|
for _, snapshot := range snapshots {
|
|
if snapshot.Template.ID == id {
|
|
return snapshot
|
|
}
|
|
}
|
|
|
|
t.Fatalf("template %q not found", id)
|
|
return catalog.Snapshot{}
|
|
}
|
|
|
|
func TestCatalogPagesAndAdminScan(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)
|
|
}
|
|
palworld := snapshotByID(t, snapshots, "palworld-official")
|
|
if err := repository.Replace(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)
|
|
}
|
|
admin, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
player, err := authService.Login(ctx, "player", "another correct battery staple", "192.0.2.2:1234")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
scanner := func(context.Context) (catalog.ScanResult, error) {
|
|
return catalog.ScanResult{Found: 2, Valid: snapshots, Errors: []catalog.ScanError{{Template: "broken", Message: "/game/image: value does not satisfy the template schema"}}}, nil
|
|
}
|
|
handler, err := NewHandlerCompleteWithCatalog(authService, repository, nil, nil, nil, nil, nil, scanner, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
adminCookies := []*http.Cookie{{Name: sessionCookie, Value: admin.Token}, {Name: csrfCookie, Value: admin.CSRFToken}}
|
|
page := request(t, handler, http.MethodGet, "/catalog", adminCookies)
|
|
assertStatus(t, page, http.StatusOK)
|
|
for _, expected := range []string{"Palworld", palworld.Template.Game.Artwork.Image, "/catalog/palworld-official", "Scan"} {
|
|
if !strings.Contains(page.Body.String(), expected) {
|
|
t.Fatalf("catalog missing %q", expected)
|
|
}
|
|
}
|
|
detail := request(t, handler, http.MethodGet, "/catalog/palworld-official", adminCookies)
|
|
assertStatus(t, detail, http.StatusOK)
|
|
for _, expected := range []string{palworld.Template.Game.Description, "Minimum", "Recommended", "Deploy"} {
|
|
if !strings.Contains(detail.Body.String(), expected) {
|
|
t.Fatalf("detail missing %q", expected)
|
|
}
|
|
}
|
|
unknown := request(t, handler, http.MethodGet, "/catalog/no-such-game", adminCookies)
|
|
assertStatus(t, unknown, http.StatusNotFound)
|
|
scan := request(t, handler, http.MethodPost, "/catalog/scan", adminCookies)
|
|
// A direct POST without a form token remains protected.
|
|
assertStatus(t, scan, http.StatusForbidden)
|
|
req := httptest.NewRequest(http.MethodPost, "/catalog/scan", strings.NewReader("csrf_token="+url.QueryEscape(admin.CSRFToken)))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
for _, cookie := range adminCookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, req)
|
|
assertStatus(t, response, http.StatusOK)
|
|
if !strings.Contains(response.Body.String(), "broken") {
|
|
t.Fatal("scan errors were not rendered")
|
|
}
|
|
nonAdmin := httptest.NewRequest(http.MethodPost, "/catalog/scan", strings.NewReader("csrf_token="+url.QueryEscape(player.CSRFToken)))
|
|
nonAdmin.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
nonAdmin.AddCookie(&http.Cookie{Name: sessionCookie, Value: player.Token})
|
|
nonAdmin.AddCookie(&http.Cookie{Name: csrfCookie, Value: player.CSRFToken})
|
|
nonAdminResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(nonAdminResponse, nonAdmin)
|
|
assertStatus(t, nonAdminResponse, http.StatusForbidden)
|
|
}
|
|
|
|
func TestDeploymentFormRequiresAdminAndRendersTemplateFields(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)
|
|
}
|
|
admin, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := authService.CreateUser(ctx, "player", "another correct battery staple", "user"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
player, err := authService.Login(ctx, "player", "another correct battery staple", "192.0.2.2:1234")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
handler, err := NewHandlerCompleteWithCatalogAndDeployment(authService, repository, instance.NewLifecycleService(repository, webLifecycleAgent{}), nil, nil, nil, nil, nil, filepath.Join(t.TempDir(), "servers"), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
adminCookies := []*http.Cookie{{Name: sessionCookie, Value: admin.Token}, {Name: csrfCookie, Value: admin.CSRFToken}}
|
|
page := request(t, handler, http.MethodGet, "/catalog/palworld-official/deploy", adminCookies)
|
|
assertStatus(t, page, http.StatusOK)
|
|
for _, expected := range []string{"server_name", "max_players", "admin_password", "DoGaMa Palworld Server"} {
|
|
if !strings.Contains(page.Body.String(), expected) {
|
|
t.Fatalf("deployment form missing %q", expected)
|
|
}
|
|
}
|
|
denied := request(t, handler, http.MethodGet, "/catalog/palworld-official/deploy", []*http.Cookie{{Name: sessionCookie, Value: player.Token}, {Name: csrfCookie, Value: player.CSRFToken}})
|
|
assertStatus(t, denied, http.StatusForbidden)
|
|
missing := request(t, handler, http.MethodGet, "/catalog/missing/deploy", adminCookies)
|
|
assertStatus(t, missing, http.StatusNotFound)
|
|
}
|
|
|
|
func TestDeploymentHTTPAsyncProgressAndRBAC(t *testing.T) { testDeploymentHTTPAsync(t, false) }
|
|
|
|
func TestDeploymentWorkerPanicFailsOperation(t *testing.T) { testDeploymentHTTPAsync(t, true) }
|
|
|
|
func testDeploymentHTTPAsync(t *testing.T, panicWorker bool) {
|
|
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)
|
|
if err := repository.SetSecretKey(bytes.Repeat([]byte{1}, 32)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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)
|
|
}
|
|
admin, err := authService.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = authService.CreateUser(ctx, "viewer", "another correct battery staple", "user"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
viewer, err := authService.Login(ctx, "viewer", "another correct battery staple", "192.0.2.2:1234")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agent := &blockingLifecycleAgent{entered: make(chan struct{}), release: make(chan struct{}), panicOnCreate: panicWorker}
|
|
handler, err := NewHandlerCompleteWithCatalogAndDeployment(authService, repository, instance.NewLifecycleService(repository, agent), nil, nil, nil, nil, nil, filepath.Join(t.TempDir(), "servers"), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var body bytes.Buffer
|
|
form := multipart.NewWriter(&body)
|
|
for key, value := range map[string]string{"csrf_token": admin.CSRFToken, "display_name": "Async test", "description": "safe", "config_server_name": "Async", "config_server_description": "safe", "config_max_players": "16", "config_admin_password": "top-secret-value", "config_rest_api_enabled": "true", "config_rest_api_port": "8212"} {
|
|
_ = form.WriteField(key, value)
|
|
}
|
|
_ = form.Close()
|
|
req := httptest.NewRequest(http.MethodPost, "/catalog/palworld-official/deploy", &body)
|
|
req.Header.Set("Content-Type", form.FormDataContentType())
|
|
req.Header.Set("Accept", "application/json")
|
|
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: admin.Token})
|
|
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: admin.CSRFToken})
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, req)
|
|
assertStatus(t, response, http.StatusAccepted)
|
|
var accepted map[string]string
|
|
if err := json.Unmarshal(response.Body.Bytes(), &accepted); err != nil || accepted["operation_id"] == "" {
|
|
t.Fatalf("accepted=%s err=%v", response.Body.String(), err)
|
|
}
|
|
// The HTTP response has returned while the worker is deterministically blocked.
|
|
<-agent.entered
|
|
progressReq := httptest.NewRequest(http.MethodGet, "/api/v1/operations/"+accepted["operation_id"], nil)
|
|
progressReq.AddCookie(&http.Cookie{Name: sessionCookie, Value: admin.Token})
|
|
progress := httptest.NewRecorder()
|
|
handler.ServeHTTP(progress, progressReq)
|
|
assertStatus(t, progress, http.StatusOK)
|
|
if strings.Contains(progress.Body.String(), "top-secret-value") || !strings.Contains(progress.Body.String(), "installation") {
|
|
t.Fatalf("unexpected progress body: %s", progress.Body.String())
|
|
}
|
|
users, _ := authService.ListUsers(ctx)
|
|
var viewerID, adminID string
|
|
for _, u := range users {
|
|
if u.Username == "viewer" {
|
|
viewerID = u.ID
|
|
}
|
|
if u.Username == "admin" {
|
|
adminID = u.ID
|
|
}
|
|
}
|
|
if err := repository.SetMembership(ctx, adminID, accepted["instance_id"], viewerID, "user"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
viewerProgress := httptest.NewRequest(http.MethodGet, "/api/v1/operations/"+accepted["operation_id"], nil)
|
|
viewerProgress.AddCookie(&http.Cookie{Name: sessionCookie, Value: viewer.Token})
|
|
viewerResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(viewerResponse, viewerProgress)
|
|
assertStatus(t, viewerResponse, http.StatusOK)
|
|
if strings.Contains(viewerResponse.Body.String(), "top-secret-value") {
|
|
t.Fatal("secret leaked to member progress")
|
|
}
|
|
diagnostic := httptest.NewRequest(http.MethodGet, "/api/v1/instances/"+accepted["instance_id"]+"/diagnostics", nil)
|
|
diagnostic.AddCookie(&http.Cookie{Name: sessionCookie, Value: viewer.Token})
|
|
diagnosticResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(diagnosticResponse, diagnostic)
|
|
assertStatus(t, diagnosticResponse, http.StatusForbidden)
|
|
unknown := httptest.NewRequest(http.MethodGet, "/api/v1/operations/missing", nil)
|
|
unknown.AddCookie(&http.Cookie{Name: sessionCookie, Value: admin.Token})
|
|
unknownResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(unknownResponse, unknown)
|
|
assertStatus(t, unknownResponse, http.StatusNotFound)
|
|
close(agent.release)
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
value, err := repository.GetOperationProgress(ctx, accepted["operation_id"])
|
|
if err == nil && ((!panicWorker && value.GlobalStatus == "success") || (panicWorker && value.GlobalStatus == "failed")) {
|
|
if panicWorker && (value.ErrorCode == "" || value.OperationID != accepted["operation_id"]) {
|
|
t.Fatalf("panic result is not actionable: %#v", value)
|
|
}
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("operation did not finish: %#v %v", value, err)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
adminDiagnostic := httptest.NewRequest(http.MethodGet, "/api/v1/instances/"+accepted["instance_id"]+"/diagnostics", nil)
|
|
adminDiagnostic.AddCookie(&http.Cookie{Name: sessionCookie, Value: admin.Token})
|
|
adminDiagnosticResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(adminDiagnosticResponse, adminDiagnostic)
|
|
assertStatus(t, adminDiagnosticResponse, http.StatusOK)
|
|
if panicWorker && (strings.Contains(adminDiagnosticResponse.Body.String(), "top-secret-value") || !strings.Contains(adminDiagnosticResponse.Body.String(), accepted["operation_id"])) {
|
|
t.Fatalf("panic diagnostic leaked secret or lost operation id: %s", adminDiagnosticResponse.Body.String())
|
|
}
|
|
}
|