feat(files): 文件管理可新建、建目录、删除、重命名和上传,写入内容拆成多个环境变量不再超内核单变量上限

This commit is contained in:
Lemon-miaow committed 2026-09-27 19:58:28 +08:00
1 parent b6751eac0f
commit 051cc1c9f5
37 files changed
+5972 -416

No files matched your search

+25 -9
View File
@@ -24,6 +24,7 @@ import (
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
)
// API holds the dependencies shared by every handler.
@@ -84,13 +85,18 @@ type API struct {
// something has to start the restore once the snapshot is done.
RestoreChains RestoreChains
// Files is the server file editor (list / read / write a file in a stopped
// server's world volume — the "one wrong line in server.properties" repair).
// Files is the server file manager (list, read, write, make a folder, delete,
// rename and upload inside a stopped server's world volume).
// Like Restorer and Backuper it is optional: when nil the file routes report
// 503, so the owner-or-admin and stopped gates are exercised before the
// file-Job executor is wired. Unlike them its calls are synchronous, because
// the caller wants the listing or the bytes back, not a 202.
Files FileEditor
// FileStage holds uploads until the Job landing them fetches them, and
// InternalBaseURL is where that Job reaches felis-api's internal face to do so
// (handleUploadFile). Uploads report 503 unless both are set.
FileStage *fileedit.Stage
InternalBaseURL string
// Submissions is the user-modpack approval lane (a user-directed extension over
// the §16 build subsystem; see internal/submit). It is optional: when
@@ -443,6 +449,11 @@ func (a *API) internalAPIRoutes() []apiRoute {
// snapshot a stopped world while the API is alive. Service-token auth (no
// Principal); the shared enqueueBackup tail enforces the RWO stopped-gate.
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/backup", Callers: ops, h: a.handleInternalBackup},
// A file upload's staged bytes, fetched once by the Job landing them. Public
// because that Job holds no service token; the one-time bearer token minted
// with the upload is the check (handlers_files.go).
{Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload},
}
}
@@ -542,13 +553,14 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/servers/{name}/jobs", h: a.handleServerJobs},
{Method: "POST", Pattern: "/api/v1/servers/{name}/restore-backup", h: a.handleRestoreBackup},
{Method: "POST", Pattern: "/api/v1/servers/{name}/backup", h: a.handleBackupNow},
// Server file editor: list / read / write a file in a STOPPED server's world
// volume (handlers_files.go). App-tier, exactly like the backup pair above and
// for the same reason — every route gates on owner-or-admin inside the handler,
// so an owner repairs their own broken server without an admin's Zero-Trust
// path. The path travels as ?path= rather than a segment because a file path
// contains '/' (the same reason DELETE /images takes ?ref=). {name}/files is
// the directory face; {name}/file is the single-file face.
// Server file manager: list, read, write, make a folder, delete, rename and
// upload in a STOPPED server's world volume (handlers_files.go). App-tier,
// exactly like the backup pair above and for the same reason — every route
// gates on owner-or-admin inside the handler, so an owner repairs their own
// broken server without an admin's Zero-Trust path. The path travels as ?path=
// rather than a segment because a file path contains '/' (the same reason
// DELETE /images takes ?ref=). {name}/files is the directory face; {name}/file
// is the single-file face.
//
// "Config editor" undersells the surface, so be precise about what app-tier
// now reaches: the mount is the server's WHOLE working directory, not a
@@ -560,6 +572,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/servers/{name}/files", h: a.handleListFiles},
{Method: "GET", Pattern: "/api/v1/servers/{name}/file", h: a.handleReadFile},
{Method: "PUT", Pattern: "/api/v1/servers/{name}/file", h: a.handleWriteFile},
{Method: "DELETE", Pattern: "/api/v1/servers/{name}/file", h: a.handleDeleteFile},
{Method: "POST", Pattern: "/api/v1/servers/{name}/files/mkdir", h: a.handleMkdir},
{Method: "POST", Pattern: "/api/v1/servers/{name}/files/rename", h: a.handleRenameFile},
{Method: "PUT", Pattern: "/api/v1/servers/{name}/files/upload", h: a.handleUploadFile},
// Account linking (spec §10), web side: /start reports link status (it is the
// pointer handleClaim's 412 emits), /verify consumes the in-game code and binds
// the account. App-tier, not admin — linking your own account is an ordinary
+266 -19
View File
@@ -3,8 +3,11 @@ package api
import (
"context"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
@@ -32,15 +35,22 @@ import (
// parallel type on this side of the seam.
//
// It returns fileedit.ErrNotFound / ErrBadPath / ErrTooLarge / ErrConflict /
// ErrNoSpace, which writeFileEditError maps to 404 / 400 / 413 / 409 / 507.
// ErrNoSpace / ErrExists, which writeFileEditError maps to 404 / 400 / 413 / 409 /
// 507 / 409.
//
// Read and Write both return the file's SHA-256 (hex). Write's expect is the hash
// a client read the file at; when set, a file that changed since is refused with
// ErrConflict instead of being overwritten.
// Read and Write return the file's SHA-256 (hex); Upload lands exactly the bytes
// src describes or fails. Write's expect is the
// hash a client read the file at; when set, a file that changed since is refused
// with ErrConflict instead of being overwritten. createOnly and a false overwrite
// refuse an existing path with ErrExists.
type FileEditor interface {
List(ctx context.Context, server, path string) (entries []fileedit.Entry, truncated bool, err error)
Read(ctx context.Context, server, path string) (content []byte, sha256 string, err error)
Write(ctx context.Context, server, path string, content []byte, expect string) (sha256 string, err error)
Write(ctx context.Context, server, path string, content []byte, expect string, createOnly bool) (sha256 string, err error)
Mkdir(ctx context.Context, server, path string) error
Delete(ctx context.Context, server, path string) error
Rename(ctx context.Context, server, path, to string) error
Upload(ctx context.Context, server, path string, src fileedit.UploadSource, overwrite bool) error
}
// writeFileRequest is the PUT /servers/{name}/file body. Content is []byte, so
@@ -58,10 +68,14 @@ type FileEditor interface {
// ExpectSHA256 is optional. The panel always sends the hash its read returned,
// so a save over a file someone else changed in the meantime answers 409
// file_changed; omitting it (a script, or "overwrite anyway") writes
// unconditionally.
// unconditionally. CreateOnly is the panel's "new file": the write lands only if
// nothing is at the path yet (409 file_exists otherwise), so it can never
// truncate a file the caller did not know was there. The two cannot be combined —
// one says the file exists, the other that it must not.
type writeFileRequest struct {
Content *[]byte `json:"content"`
ExpectSHA256 string `json:"expect_sha256,omitempty"`
CreateOnly bool `json:"create_only,omitempty"`
}
// handleListFiles serves GET /api/v1/servers/{name}/files?path=… — one directory's
@@ -105,10 +119,8 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
path, ok := requirePath(w, r)
if !ok {
return
}
@@ -140,10 +152,8 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
path, ok := requirePath(w, r)
if !ok {
return
}
@@ -173,6 +183,11 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
"expect_sha256 must be the 64-digit lowercase hex sha256 a read returned"))
return
}
if body.CreateOnly && body.ExpectSHA256 != "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"create_only and expect_sha256 cannot be combined"))
return
}
// A write holds the world volume for its Job's lifetime (internal/maintenance);
// reads and listings do not, since a read-only mount cannot hurt a server
@@ -183,19 +198,249 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
}
defer release()
sum, err := a.Files.Write(r.Context(), name, path, *body.Content, body.ExpectSHA256)
sum, err := a.Files.Write(r.Context(), name, path, *body.Content, body.ExpectSHA256, body.CreateOnly)
if err != nil {
writeFileEditError(w, r, err)
return
}
a.audit(r, "file.write", name+":"+path)
a.auditFile(r, "file.write", name, path, nil)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "written", "sha256": sum})
}
// requirePath reads the required ?path= query parameter, answering 400 when it
// is absent.
func requirePath(w http.ResponseWriter, r *http.Request) (string, bool) {
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
return "", false
}
return path, true
}
// handleMkdir serves POST /api/v1/servers/{name}/files/mkdir?path=… — make one
// folder. Its parent must exist (404 otherwise) and nothing may be at the path yet
// (409 file_exists). Like every file change it holds the world lock and is
// audited.
func (a *API) handleMkdir(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
if err := a.Files.Mkdir(r.Context(), name, path); err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.mkdir", name, path, nil)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "created"})
}
// handleDeleteFile serves DELETE /api/v1/servers/{name}/file?path=… — delete a
// file, a symlink (never what it points at), or a folder with everything in it.
// The world root itself is refused (400 bad_path). The panel confirms first; this
// route does not, because a script that says DELETE means it.
func (a *API) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
if err := a.Files.Delete(r.Context(), name, path); err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.delete", name, path, nil)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "deleted"})
}
// renameFileRequest is the POST /servers/{name}/files/rename body: the new path,
// relative to the world root like ?path=.
type renameFileRequest struct {
To string `json:"to"`
}
// handleRenameFile serves POST /api/v1/servers/{name}/files/rename?path=… — move
// a file or folder to body.to. It never replaces: an existing destination is 409
// file_exists. server.properties, config/paper-global.yml and config/ cannot be
// moved (400 bad_path), since under another name the read path would no longer
// know to withhold their secrets.
func (a *API) handleRenameFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
var body renameFileRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
if body.To == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "the to field is required"))
return
}
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
if err := a.Files.Rename(r.Context(), name, path, body.To); err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.rename", name, path, map[string]any{"to": body.To})
writeJSON(w, http.StatusOK, map[string]any{"path": path, "to": body.To, "status": "renamed"})
}
// handleUploadFile serves PUT /api/v1/servers/{name}/files/upload?path=… — land
// the raw request body as a file, up to fileedit.MaxUploadBytes. An existing file
// is 409 file_exists unless ?overwrite=true.
//
// The body is staged on felis-api's disk first (fileedit.Stage) and fetched from
// there by the Job, on the internal face, with a one-time token: it fits in
// neither a Job spec nor an environment. The world lock is taken only once the
// body has arrived, so a slow upload does not hold off a backup; the stopped gate
// ran before the body was read and the lock re-checks that nothing started since.
//
// Content-Length is required (411 length_required): the stage reserves room for
// the declared size before a byte is written, and a size promised up front is
// what lets a short body be told from a whole one.
func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
if a.FileStage == nil || a.InternalBaseURL == "" {
writeError(w, r, newError(http.StatusServiceUnavailable, "files_unavailable",
"uploads are not configured"))
return
}
if r.ContentLength < 0 {
writeError(w, r, newError(http.StatusLengthRequired, "length_required",
"an upload needs a Content-Length"))
return
}
if r.ContentLength > fileedit.MaxUploadBytes {
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "too_large",
"the file is %d bytes; uploads are at most %d", r.ContentLength, fileedit.MaxUploadBytes))
return
}
overwrite := r.URL.Query().Get("overwrite") == "true"
staged, drop, err := a.FileStage.Put(r.Body, r.ContentLength)
switch {
case errors.Is(err, fileedit.ErrStageFull):
writeError(w, r, newError(http.StatusInsufficientStorage, "upload_staging_full",
"felis has no room to take this upload right now; try again later or ask an admin"))
return
case errors.Is(err, fileedit.ErrShortUpload):
writeError(w, r, newError(http.StatusBadRequest, "upload_incomplete",
"the upload ended before all %d bytes arrived", r.ContentLength))
return
case err != nil:
writeError(w, r, err)
return
}
defer drop()
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
err = a.Files.Upload(r.Context(), name, path, fileedit.UploadSource{
URL: a.InternalBaseURL + "/api/v1/internal/file-uploads/" + staged.ID,
Token: staged.Token,
Size: staged.Size,
SHA256: staged.SHA256,
}, overwrite)
if err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.upload", name, path, map[string]any{
"size_bytes": staged.Size, "sha256": staged.SHA256, "overwrite": overwrite,
})
writeJSON(w, http.StatusOK, map[string]any{
"path": path, "status": "uploaded", "sha256": staged.SHA256, "size": staged.Size,
})
}
// handleInternalFileUpload serves GET /api/v1/internal/file-uploads/{id} — the
// staged bytes of one upload, to the one Job created to land them. It is Public on
// the internal face: the Job holds no service token (it holds no credential at
// all), so the bearer token minted with the upload is the whole check, and it
// opens that upload once. An unknown id, a wrong token and a spent one are the
// same 404, so the route answers nothing about which uploads exist.
func (a *API) handleInternalFileUpload(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if a.FileStage == nil || !ok {
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such upload"))
return
}
f, size, err := a.FileStage.Open(r.PathValue("id"), token)
if errors.Is(err, fileedit.ErrNotStaged) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such upload"))
return
}
if err != nil {
writeError(w, r, err)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, f)
}
// auditFile records a file change. The target is "<server>:<path>", as file.write
// has always recorded it; extra, when set, is the payload.
func (a *API) auditFile(r *http.Request, action, server, path string, extra map[string]any) {
p := principalFromContext(r.Context())
e := AuditEntry{Actor: auditActor(p), Action: action, ServerName: server + ":" + path}
if p != nil {
e.ActorUserID = p.UserID
}
if extra != nil {
e.Payload = auditPayload(extra)
}
a.auditEntry(r, e)
}
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// authorizeFileOp is the shared front half of all three file handlers — the gate
// authorizeFileOp is the shared front half of every file handler — the gate
// that decides whether this caller may touch this server's world at all. It
// mirrors the backup/restore gate step for step, because it is guarding the same
// resource under the same physical constraint:
@@ -211,7 +456,7 @@ var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// silently fails to mount
// ⑤ the FileEditor must be wired, else 503
//
// Single-sourcing it is what keeps the three faces from drifting: a read path that
// Single-sourcing it is what keeps the handlers from drifting: a read path that
// forgot the stopped gate would not merely fail, it would hang waiting for a Pod
// that can never be scheduled.
//
@@ -270,7 +515,7 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b
return name, true
}
// writeFileEditError maps executor errors onto HTTP status codes. The three
// writeFileEditError maps executor errors onto HTTP status codes. The
// sentinels are caller-fault and get precise answers; a timeout is reported as 504
// so the caller knows to retry rather than believing the edit was rejected; and
// anything else collapses to a 500 by writeError, so no cluster detail leaks.
@@ -291,6 +536,8 @@ func writeFileEditError(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, r, newError(http.StatusConflict, "file_changed", "%s", err.Error()))
case errors.Is(err, fileedit.ErrNoSpace):
writeError(w, r, newError(http.StatusInsufficientStorage, "volume_full", "%s", err.Error()))
case errors.Is(err, fileedit.ErrExists):
writeError(w, r, newError(http.StatusConflict, "file_exists", "%s", err.Error()))
case errors.Is(err, context.DeadlineExceeded):
writeError(w, r, newError(http.StatusGatewayTimeout, "files_timeout",
"the file operation did not finish in time; retry shortly"))
+474 -60
View File
@@ -2,14 +2,21 @@ package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strconv"
"strings"
"testing"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
)
// fakeFileEditor records what the handlers ask the executor to do and returns
@@ -19,11 +26,19 @@ import (
type fakeFileEditor struct {
err error
calls int
gotServer string
gotPath string
gotContent []byte
gotExpect string
calls int
gotOp string
gotServer string
gotPath string
gotContent []byte
gotExpect string
gotCreateOnly bool
gotTo string
gotSource fileedit.UploadSource
gotOverwrite bool
// onUpload, when set, runs inside Upload the way the real Job fetches the
// staged bytes while the handler waits.
onUpload func(fileedit.UploadSource)
entries []fileedit.Entry
truncated bool
@@ -43,19 +58,64 @@ func (f *fakeFileEditor) Read(_ context.Context, server, path string) ([]byte, s
return f.content, f.sum, f.err
}
func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte, expect string) (string, error) {
func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte, expect string, createOnly bool) (string, error) {
f.calls++
f.gotServer, f.gotPath, f.gotContent, f.gotExpect = server, path, content, expect
f.gotOp = fileedit.OpWrite
f.gotServer, f.gotPath, f.gotContent, f.gotExpect, f.gotCreateOnly = server, path, content, expect, createOnly
return f.sum, f.err
}
func (f *fakeFileEditor) Mkdir(_ context.Context, server, path string) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath = fileedit.OpMkdir, server, path
return f.err
}
func (f *fakeFileEditor) Delete(_ context.Context, server, path string) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath = fileedit.OpDelete, server, path
return f.err
}
func (f *fakeFileEditor) Rename(_ context.Context, server, path, to string) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath, f.gotTo = fileedit.OpRename, server, path, to
return f.err
}
func (f *fakeFileEditor) Upload(_ context.Context, server, path string, src fileedit.UploadSource, overwrite bool) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath, f.gotSource, f.gotOverwrite = fileedit.OpUpload, server, path, src, overwrite
if f.onUpload != nil {
f.onUpload(src)
}
return f.err
}
// fileRouteHeader is the Content-Type a file route's body goes with: raw bytes
// for an upload, JSON for any other body.
func fileRouteHeader(name, body string) map[string]string {
switch {
case name == "upload":
return ctHeader("application/octet-stream")
case body != "":
return jsonHeader
}
return nil
}
// testSum is a well-formed sha256 hex digest for the fake to hand out.
var testSum = strings.Repeat("a", 64)
// mkFiles builds an API whose "survival" server is STOPPED and owned by owner1,
// with a wired fakeFileEditor — the state in which every file operation is
// permitted, so each subtest changes exactly the one thing it is about.
func mkFiles() (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) {
//
// Uploads stage in a per-test directory. MinFree is near zero because the
// staging floor is fileedit's to test, and the machine running the tests may
// well have less than 10% of its disk free.
func mkFiles(t *testing.T) (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) {
t.Helper()
repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
cl := newFakeCluster()
@@ -64,13 +124,15 @@ func mkFiles() (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) {
files := &fakeFileEditor{}
api := newTestAPI(repo, cl)
api.Files = files
api.FileStage = &fileedit.Stage{Dir: t.TempDir(), MinFree: 1e-9}
api.InternalBaseURL = "http://felis-api-internal.felis.svc.cluster.local:8081"
return api, repo, cl, files
}
// TestFileEditorStoppedGate is the gate this whole subsystem hinges on. The world
// PVC is ReadWriteOnce, but RWO is per node: on a single node a file Job mounts it
// right beside a running server, and a write lands under a live world that the
// server's next save overwrites or tears. Every one of the three routes
// server's next save overwrites or tears. Every route
// must therefore refuse a non-stopped server with 409 not_stopped BEFORE reaching
// the executor, which is why each asserts calls == 0 as well as the status.
func TestFileEditorStoppedGate(t *testing.T) {
@@ -85,6 +147,10 @@ func TestFileEditorStoppedGate(t *testing.T) {
{"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
{"upload", "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", "PK-jar-bytes"},
}
// Both non-stopped shapes matter and they are different states: a server that is
@@ -103,16 +169,12 @@ func TestFileEditorStoppedGate(t *testing.T) {
for _, rt := range routes {
for _, st := range states {
t.Run(fmt.Sprintf("%s on a %s server -> 409 not_stopped", rt.name, st.name), func(t *testing.T) {
api, _, cl, files := mkFiles()
api, _, cl, files := mkFiles(t)
cl.byName["survival"].Ready = st.ready
cl.byName["survival"].DesiredState = string(st.desiredState)
api.External = staticExternal{p: owner}
var hdr map[string]string
if rt.body != "" {
hdr = jsonHeader
}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr)
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusConflict || decodeErr(t, w) != "not_stopped" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
@@ -128,7 +190,7 @@ func TestFileEditorStoppedGate(t *testing.T) {
// world PVC (never started, or already reaped) has no claim for the Job to mount,
// so its Pod would sit Pending until the executor's wait timed out — a 90s hang
// and a misleading 504 files_timeout for a request that is knowably impossible.
// All three routes must refuse BEFORE creating a Job, with the same specific 409
// Every route must refuse BEFORE creating a Job, with the same specific 409
// the backup/restore faces use.
func TestFileEditorWorldVolumeGate(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
@@ -142,19 +204,19 @@ func TestFileEditorWorldVolumeGate(t *testing.T) {
{"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
{"upload", "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", "PK-jar-bytes"},
}
for _, rt := range routes {
t.Run(rt.name+" without a world volume -> 409 no_world_volume", func(t *testing.T) {
api, _, cl, files := mkFiles()
api, _, cl, files := mkFiles(t)
cl.noWorld["survival"] = true
api.External = staticExternal{p: owner}
var hdr map[string]string
if rt.body != "" {
hdr = jsonHeader
}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr)
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusConflict || decodeErr(t, w) != "no_world_volume" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
@@ -166,8 +228,8 @@ func TestFileEditorWorldVolumeGate(t *testing.T) {
}
// TestFileEditorAuthorization pins who may touch a world's files. It is the same
// owner-or-admin rule the backup routes enforce, and it must hold on all three
// routes — a read-only route leaking another owner's config (an RCON password
// owner-or-admin rule the backup routes enforce, and it must hold on every
// route — a read-only route leaking another owner's config (an RCON password
// lives in server.properties) would be as bad as an unauthorized write.
func TestFileEditorAuthorization(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
@@ -183,20 +245,17 @@ func TestFileEditorAuthorization(t *testing.T) {
{"list", "GET", "/api/v1/servers/survival/files", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
}
hdrFor := func(body string) map[string]string {
if body != "" {
return jsonHeader
}
return nil
{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
{"upload", "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", "PK-jar-bytes"},
}
for _, rt := range routes {
t.Run(rt.name+": non-owner -> 403, executor untouched", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: stranger}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
@@ -206,9 +265,9 @@ func TestFileEditorAuthorization(t *testing.T) {
})
t.Run(rt.name+": owner -> allowed", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
@@ -218,10 +277,10 @@ func TestFileEditorAuthorization(t *testing.T) {
})
t.Run(rt.name+": admin on someone else's server -> allowed", func(t *testing.T) {
api, repo, _, files := mkFiles()
api, repo, _, files := mkFiles(t)
repo.byName["survival"].OwnerID = "someone-else"
api.External = staticExternal{p: admin}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
@@ -231,40 +290,40 @@ func TestFileEditorAuthorization(t *testing.T) {
})
t.Run(rt.name+": unowned server -> 403 for a plain user", func(t *testing.T) {
api, repo, _, _ := mkFiles()
api, repo, _, _ := mkFiles(t)
repo.byName["survival"].OwnerID = "" // released world
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
})
t.Run(rt.name+": unknown server -> 404", func(t *testing.T) {
api, _, _, _ := mkFiles()
api, _, _, _ := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method,
strings.Replace(rt.path, "survival", "missing", 1), rt.body, hdrFor(rt.body))
strings.Replace(rt.path, "survival", "missing", 1), rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
}
})
t.Run(rt.name+": invalid server name -> 400 bad_name", func(t *testing.T) {
api, _, _, _ := mkFiles()
api, _, _, _ := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method,
strings.Replace(rt.path, "survival", "X", 1), rt.body, hdrFor(rt.body))
strings.Replace(rt.path, "survival", "X", 1), rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_name" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run(rt.name+": nil FileEditor -> 503 files_unavailable", func(t *testing.T) {
api, _, _, _ := mkFiles()
api, _, _, _ := mkFiles(t)
api.Files = nil
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
@@ -278,7 +337,7 @@ func TestFileEditorHandlers(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
t.Run("list passes the path through and returns entries", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
files.entries = []fileedit.Entry{{Name: "paper.yml", Size: 12}, {Name: "sub", IsDir: true}}
files.truncated = true
api.External = staticExternal{p: owner}
@@ -304,7 +363,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("list without a path lists the world root", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files", "", nil)
if w.Code != http.StatusOK {
@@ -316,7 +375,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("read returns base64 content and its hash", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
files.content = []byte("motd=hello\n")
files.sum = testSum
api.External = staticExternal{p: owner}
@@ -339,7 +398,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("read without a path -> 400", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file", "", nil)
if w.Code != http.StatusBadRequest {
@@ -351,7 +410,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("write decodes content, audits, and answers 200", func(t *testing.T) {
api, repo, _, files := mkFiles()
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -369,7 +428,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("write passes the expected hash through and returns the new one", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
files.sum = strings.Repeat("b", 64)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -390,7 +449,7 @@ func TestFileEditorHandlers(t *testing.T) {
t.Run("a malformed expected hash -> 400 before the executor", func(t *testing.T) {
for _, bad := range []string{"abc", strings.Repeat("A", 64), strings.Repeat("a", 63) + " ", "--op=list"} {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
body, _ := json.Marshal(map[string]any{"content": []byte("hi"), "expect_sha256": bad})
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -402,7 +461,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("a stale write -> 409 file_changed, not audited", func(t *testing.T) {
api, repo, _, files := mkFiles()
api, repo, _, files := mkFiles(t)
files.err = fmt.Errorf("%w: server.properties has changed", fileedit.ErrConflict)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -416,7 +475,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("reads are not audited", func(t *testing.T) {
api, repo, _, _ := mkFiles()
api, repo, _, _ := mkFiles(t)
api.External = staticExternal{p: owner}
do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files", "", nil)
do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil)
@@ -426,7 +485,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("oversized write -> 413 before the executor", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
// base64 of MaxWriteBytes+1 zero bytes, built as a JSON body.
body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes+1))})
@@ -450,7 +509,7 @@ func TestFileEditorHandlers(t *testing.T) {
// because a deliberate truncate is a real edit; only the OMISSION is refused.
t.Run("a write with no content field -> 400, never a truncate", func(t *testing.T) {
for _, body := range []string{`{}`, `{"content":null}`} {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
body, jsonHeader)
@@ -464,7 +523,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("an explicit empty content is a legitimate truncate", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":""}`, jsonHeader)
@@ -478,7 +537,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("a write at exactly the limit is allowed", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes))})
if err != nil {
@@ -495,6 +554,360 @@ func TestFileEditorHandlers(t *testing.T) {
})
}
// fileAnswer decodes a file route's JSON answer for an exact comparison.
func fileAnswer(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
return m
}
// onlyAudit asserts the request wrote exactly one audit row, by owner1, with
// this action, target and payload ("" for none).
func onlyAudit(t *testing.T, repo *fakeRepo, action, target, payload string) {
t.Helper()
if len(repo.audits) != 1 {
t.Fatalf("audits = %+v, want exactly one %s", repo.audits, action)
}
a := repo.audits[0]
if a.Action != action || a.ServerName != target || a.Actor != "[email protected]" ||
a.ActorUserID != "owner1" || string(a.Payload) != payload {
t.Fatalf("audit = %+v (payload %s), want %s on %s with payload %q", a, a.Payload, action, target, payload)
}
}
// TestFileManagerHandlers covers the routes that change a world's file tree
// beyond a save: what each hands the executor, what it answers, what it audits.
func TestFileManagerHandlers(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
t.Run("mkdir makes the folder and audits it", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/files/mkdir?path=plugins/Essentials", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if files.calls != 1 || files.gotOp != fileedit.OpMkdir || files.gotPath != "plugins/Essentials" {
t.Fatalf("executor saw %d calls, op %q, path %q", files.calls, files.gotOp, files.gotPath)
}
if got, want := fileAnswer(t, w), (map[string]any{"path": "plugins/Essentials", "status": "created"}); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.mkdir", "survival:plugins/Essentials", "")
})
t.Run("delete removes the path and audits it", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "DELETE", "/api/v1/servers/survival/file?path=plugins/old.jar", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if files.calls != 1 || files.gotOp != fileedit.OpDelete || files.gotPath != "plugins/old.jar" {
t.Fatalf("executor saw %d calls, op %q, path %q", files.calls, files.gotOp, files.gotPath)
}
if got, want := fileAnswer(t, w), (map[string]any{"path": "plugins/old.jar", "status": "deleted"}); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.delete", "survival:plugins/old.jar", "")
})
t.Run("rename passes the destination and audits both names", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/files/rename?path=plugins/a.jar",
`{"to":"plugins/disabled/a.jar"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if files.calls != 1 || files.gotOp != fileedit.OpRename || files.gotPath != "plugins/a.jar" || files.gotTo != "plugins/disabled/a.jar" {
t.Fatalf("executor saw %d calls, op %q, %q -> %q", files.calls, files.gotOp, files.gotPath, files.gotTo)
}
want := map[string]any{"path": "plugins/a.jar", "to": "plugins/disabled/a.jar", "status": "renamed"}
if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.rename", "survival:plugins/a.jar", `{"to":"plugins/disabled/a.jar"}`)
})
t.Run("rename without a destination -> 400 before the executor", func(t *testing.T) {
for _, body := range []string{`{}`, `{"to":""}`} {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/files/rename?path=a.txt", body, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" || files.calls != 0 {
t.Fatalf("body %s: code = %d calls = %d (%s), want 400 and no Job", body, w.Code, files.calls, w.Body.String())
}
}
})
t.Run("a route that changes a path needs the path", func(t *testing.T) {
for _, rt := range []struct{ method, path, body string }{
{"POST", "/api/v1/servers/survival/files/mkdir", ""},
{"DELETE", "/api/v1/servers/survival/file", ""},
{"POST", "/api/v1/servers/survival/files/rename", `{"to":"b.txt"}`},
{"PUT", "/api/v1/servers/survival/files/upload", "bytes"},
} {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
hdr := jsonHeader
if rt.method == "PUT" {
hdr = ctHeader("application/octet-stream")
}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" || files.calls != 0 {
t.Fatalf("%s %s: code = %d calls = %d (%s), want 400 and no Job", rt.method, rt.path, w.Code, files.calls, w.Body.String())
}
}
})
t.Run("a refused change is not audited", func(t *testing.T) {
for _, rt := range []struct{ method, path, body string }{
{"POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"DELETE", "/api/v1/servers/survival/file?path=plugins", ""},
{"POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
} {
api, repo, _, files := mkFiles(t)
files.err = fmt.Errorf("%w: plugins already exists", fileedit.ErrExists)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, jsonHeader)
if w.Code != http.StatusConflict || decodeErr(t, w) != "file_exists" {
t.Fatalf("%s %s: code = %d (%s), want 409 file_exists", rt.method, rt.path, w.Code, w.Body.String())
}
if len(repo.audits) != 0 {
t.Fatalf("%s %s: a refused change was audited: %+v", rt.method, rt.path, repo.audits)
}
}
})
t.Run("create_only reaches the executor", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=plugins/new.yml",
`{"content":"","create_only":true}`, jsonHeader)
if w.Code != http.StatusOK || !files.gotCreateOnly || files.gotExpect != "" {
t.Fatalf("code = %d, createOnly = %v, expect = %q (%s)", w.Code, files.gotCreateOnly, files.gotExpect, w.Body.String())
}
w = do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":"aGk="}`, jsonHeader)
if w.Code != http.StatusOK || files.gotCreateOnly {
t.Fatalf("a plain save: code = %d, createOnly = %v", w.Code, files.gotCreateOnly)
}
})
t.Run("create_only with an expected hash -> 400", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=a.yml",
`{"content":"","create_only":true,"expect_sha256":"`+testSum+`"}`, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s), want 400 and no Job", w.Code, files.calls, w.Body.String())
}
})
}
// doUpload sends an upload whose Content-Length is declared, not measured, the
// way a client that streams or lies would send it.
func doUpload(h http.Handler, body string, length int64) *httptest.ResponseRecorder {
r := httptest.NewRequest("PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", strings.NewReader(body))
r.Header.Set("Content-Type", "application/octet-stream")
r.ContentLength = length
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
recordContract(r, body, w)
return w
}
// stageEmpty asserts no staged upload is left on disk.
func stageEmpty(t *testing.T, api *API) {
t.Helper()
left, err := os.ReadDir(api.FileStage.Dir)
if err != nil {
t.Fatalf("read the stage: %v", err)
}
if len(left) != 0 {
t.Fatalf("staged files left behind: %v", left)
}
}
// TestFileUpload drives an upload across both faces: the external PUT stages the
// body, and the executor, standing in for the Job, fetches it from the internal
// face with the token it was handed, as cmd/felis files does.
func TestFileUpload(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
const body = "PK\x03\x04 a plugin jar"
digest := sha256.Sum256([]byte(body))
sum := hex.EncodeToString(digest[:])
const route = "/api/v1/servers/survival/files/upload?path=plugins/x.jar"
octet := ctHeader("application/octet-stream")
t.Run("the Job fetches the body once, with its token alone", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
// Every other internal route wants a service token; the Job has none.
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
prefix := api.InternalBaseURL + "/api/v1/internal/file-uploads/"
var bare, wrong, unschemed, fetched, again *httptest.ResponseRecorder
files.onUpload = func(src fileedit.UploadSource) {
id, ok := strings.CutPrefix(src.URL, prefix)
if !ok || len(id) != 32 {
t.Errorf("source URL %q is not one id under %q", src.URL, prefix)
return
}
h := api.InternalHandler()
at := "/api/v1/internal/file-uploads/" + id
bare = do(h, "GET", at, "", nil)
wrong = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + strings.Repeat("0", len(src.Token))})
unschemed = do(h, "GET", at, "", map[string]string{"Authorization": src.Token})
fetched = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
again = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
}
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if fetched == nil {
t.Fatal("the executor never fetched the upload")
}
for name, r := range map[string]*httptest.ResponseRecorder{
"no token": bare, "wrong token": wrong, "the token without Bearer": unschemed, "second fetch": again,
} {
if r.Code != http.StatusNotFound || decodeErr(t, r) != "not_found" {
t.Errorf("%s: code = %d (%s), want 404 not_found", name, r.Code, r.Body.String())
}
}
if fetched.Code != http.StatusOK || fetched.Body.String() != body ||
fetched.Header().Get("Content-Length") != strconv.Itoa(len(body)) {
t.Fatalf("fetch: code = %d, %q, Content-Length %q", fetched.Code, fetched.Body.String(), fetched.Header().Get("Content-Length"))
}
if files.gotPath != "plugins/x.jar" || files.gotSource.Size != int64(len(body)) ||
files.gotSource.SHA256 != sum || files.gotOverwrite {
t.Fatalf("executor saw path %q, source %+v, overwrite %v", files.gotPath, files.gotSource, files.gotOverwrite)
}
want := map[string]any{"path": "plugins/x.jar", "status": "uploaded", "sha256": sum, "size": float64(len(body))}
if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.upload", "survival:plugins/x.jar",
`{"overwrite":false,"sha256":"`+sum+`","size_bytes":`+strconv.Itoa(len(body))+`}`)
stageEmpty(t, api)
})
t.Run("overwrite=true reaches the executor", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", route+"&overwrite=true", body, octet)
if w.Code != http.StatusOK || !files.gotOverwrite {
t.Fatalf("code = %d, overwrite = %v (%s)", w.Code, files.gotOverwrite, w.Body.String())
}
onlyAudit(t, repo, "file.upload", "survival:plugins/x.jar",
`{"overwrite":true,"sha256":"`+sum+`","size_bytes":`+strconv.Itoa(len(body))+`}`)
})
t.Run("a refused upload is not audited and its bytes are dropped", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
files.err = fmt.Errorf("%w: plugins/x.jar already exists", fileedit.ErrExists)
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusConflict || decodeErr(t, w) != "file_exists" {
t.Fatalf("code = %d (%s), want 409 file_exists", w.Code, w.Body.String())
}
if len(repo.audits) != 0 {
t.Fatalf("a refused upload was audited: %+v", repo.audits)
}
stageEmpty(t, api)
})
t.Run("a lock that cannot be taken drops the staged bytes", func(t *testing.T) {
api, _, cl, files := mkFiles(t)
api.External = staticExternal{p: owner}
cl.maintErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindBackup}
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusConflict || decodeErr(t, w) != "maintenance_in_progress" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
stageEmpty(t, api)
})
t.Run("no Content-Length -> 411", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, -1)
if w.Code != http.StatusLengthRequired || decodeErr(t, w) != "length_required" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
})
t.Run("a declared size over the cap -> 413 before a byte is staged", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, fileedit.MaxUploadBytes+1)
if w.Code != http.StatusRequestEntityTooLarge || decodeErr(t, w) != "too_large" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
stageEmpty(t, api)
})
// Staged, and so short: the body is a few bytes of a declared 64 MiB.
t.Run("a declared size at the cap is taken", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, fileedit.MaxUploadBytes)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "upload_incomplete" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
})
t.Run("a body shorter than its Content-Length -> 400 upload_incomplete", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, int64(len(body))+1)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "upload_incomplete" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
stageEmpty(t, api)
})
t.Run("a staging disk at its floor -> 507", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
api.FileStage.MinFree = 1
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusInsufficientStorage || decodeErr(t, w) != "upload_staging_full" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
})
t.Run("uploads not wired -> 503", func(t *testing.T) {
for name, unwire := range map[string]func(*API){
"no stage": func(a *API) { a.FileStage = nil },
"no internal URL": func(a *API) { a.InternalBaseURL = "" },
} {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
unwire(api)
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" || files.calls != 0 {
t.Fatalf("%s: code = %d calls = %d (%s)", name, w.Code, files.calls, w.Body.String())
}
}
})
t.Run("the internal route without a stage -> 404", func(t *testing.T) {
api, _, _, _ := mkFiles(t)
api.FileStage = nil
w := do(api.InternalHandler(), "GET", "/api/v1/internal/file-uploads/00112233445566778899aabbccddeeff", "",
map[string]string{"Authorization": "Bearer t"})
if w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
})
}
// TestFileEditorErrorMapping proves each executor sentinel reaches the caller as the
// right status. The containment refusal mapping to 400 (not 403) is the one worth
// stating: an escaping path is a malformed request, not a permission a caller might
@@ -513,12 +926,13 @@ func TestFileEditorErrorMapping(t *testing.T) {
{"oversized file", fmt.Errorf("%w: nope", fileedit.ErrTooLarge), http.StatusRequestEntityTooLarge, "too_large"},
{"changed since read", fmt.Errorf("%w: nope", fileedit.ErrConflict), http.StatusConflict, "file_changed"},
{"volume full", fmt.Errorf("%w: nope", fileedit.ErrNoSpace), http.StatusInsufficientStorage, "volume_full"},
{"already there", fmt.Errorf("%w: nope", fileedit.ErrExists), http.StatusConflict, "file_exists"},
{"timeout", fmt.Errorf("waiting: %w", context.DeadlineExceeded), http.StatusGatewayTimeout, "files_timeout"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
files.err = tc.err
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil)
@@ -529,7 +943,7 @@ func TestFileEditorErrorMapping(t *testing.T) {
}
t.Run("an unrecognised executor failure -> 500", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
files.err = fmt.Errorf("the job pod exploded")
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil)
+19 -9
View File
@@ -6,9 +6,11 @@ import (
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
)
@@ -84,7 +86,8 @@ type maintenanceOp struct {
calls func() int
}
func maintenanceOps() (*API, *fakeCluster, []maintenanceOp) {
func maintenanceOps(t *testing.T) (*API, *fakeCluster, []maintenanceOp) {
t.Helper()
repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
repo.backups = []fakeBackup{{view: BackupView{ID: "bk1", ServerName: "survival",
@@ -95,6 +98,8 @@ func maintenanceOps() (*API, *fakeCluster, []maintenanceOp) {
restorer, backuper, files := &fakeRestorer{}, &fakeBackuper{}, &fakeFileEditor{}
api := newTestAPI(repo, cl)
api.Restorer, api.Backuper, api.Files = restorer, backuper, files
api.FileStage = &fileedit.Stage{Dir: t.TempDir(), MinFree: 1e-9}
api.InternalBaseURL = "http://felis-api-internal.felis.svc.cluster.local:8081"
api.External = staticExternal{p: &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}}
return api, cl, []maintenanceOp{
{"restore", maintenance.KindRestore, "POST", "/api/v1/servers/survival/restore-backup", "",
@@ -103,22 +108,27 @@ func maintenanceOps() (*API, *fakeCluster, []maintenanceOp) {
func() int { return backuper.calls }},
{"file write", maintenance.KindFileWrite, "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":"aGk="}`, func() int { return files.calls }},
{"file mkdir", maintenance.KindFileWrite, "POST", "/api/v1/servers/survival/files/mkdir?path=plugins",
"", func() int { return files.calls }},
{"file delete", maintenance.KindFileWrite, "DELETE", "/api/v1/servers/survival/file?path=old.jar",
"", func() int { return files.calls }},
{"file rename", maintenance.KindFileWrite, "POST", "/api/v1/servers/survival/files/rename?path=a.txt",
`{"to":"b.txt"}`, func() int { return files.calls }},
{"file upload", maintenance.KindFileWrite, "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar",
"PK-jar-bytes", func() int { return files.calls }},
}
}
func (op maintenanceOp) do(api *API) *httptest.ResponseRecorder {
var hdr map[string]string
if op.body != "" {
hdr = jsonHeader
}
hdr := fileRouteHeader(strings.TrimPrefix(op.name, "file "), op.body)
return do(api.ExternalHandler(), op.method, op.path, op.body, hdr)
}
func TestMaintenanceOpsTakeAndReleaseTheLock(t *testing.T) {
_, _, ops := maintenanceOps()
_, _, ops := maintenanceOps(t)
for i := range ops {
t.Run(ops[i].name, func(t *testing.T) {
api, cl, ops := maintenanceOps()
api, cl, ops := maintenanceOps(t)
op := ops[i]
if w := op.do(api); w.Code/100 != 2 {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
@@ -147,11 +157,11 @@ func TestMaintenanceOpsRefusedWhileHeld(t *testing.T) {
// wake won the race.
{"server not stopped", fmt.Errorf("wrapped: %w", ErrNotStopped), "not_stopped"},
}
_, _, ops := maintenanceOps()
_, _, ops := maintenanceOps(t)
for i := range ops {
for _, rf := range refusals {
t.Run(ops[i].name+" / "+rf.name, func(t *testing.T) {
api, cl, ops := maintenanceOps()
api, cl, ops := maintenanceOps(t)
op := ops[i]
cl.maintErr["survival"] = rf.err
w := op.do(api)