feat(files): 文件管理可新建、建目录、删除、重命名和上传,写入内容拆成多个环境变量不再超内核单变量上限
This commit is contained in:
37 files changed
+5972
-416
No files matched your search
+25
-9
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user