545 lines
21 KiB
Go
545 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
|
"felis.lolicon.best/internal/fileedit"
|
|
)
|
|
|
|
// fakeFileEditor records what the handlers ask the executor to do and returns
|
|
// canned results. The real executor runs a Job and reads its log back; none of
|
|
// that is the handlers' business, so the fake collapses it to "what was asked,
|
|
// and what came back".
|
|
type fakeFileEditor struct {
|
|
err error
|
|
|
|
calls int
|
|
gotServer string
|
|
gotPath string
|
|
gotContent []byte
|
|
gotExpect string
|
|
|
|
entries []fileedit.Entry
|
|
truncated bool
|
|
content []byte
|
|
sum string
|
|
}
|
|
|
|
func (f *fakeFileEditor) List(_ context.Context, server, path string) ([]fileedit.Entry, bool, error) {
|
|
f.calls++
|
|
f.gotServer, f.gotPath = server, path
|
|
return f.entries, f.truncated, f.err
|
|
}
|
|
|
|
func (f *fakeFileEditor) Read(_ context.Context, server, path string) ([]byte, string, error) {
|
|
f.calls++
|
|
f.gotServer, f.gotPath = server, path
|
|
return f.content, f.sum, f.err
|
|
}
|
|
|
|
func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte, expect string) (string, error) {
|
|
f.calls++
|
|
f.gotServer, f.gotPath, f.gotContent, f.gotExpect = server, path, content, expect
|
|
return f.sum, f.err
|
|
}
|
|
|
|
// 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) {
|
|
repo := newFakeRepo()
|
|
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
|
cl := newFakeCluster()
|
|
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped",
|
|
Ready: false, DesiredState: string(v1alpha1.DesiredStopped)}
|
|
files := &fakeFileEditor{}
|
|
api := newTestAPI(repo, cl)
|
|
api.Files = files
|
|
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
|
|
// 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) {
|
|
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
|
|
|
|
routes := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{"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="}`},
|
|
}
|
|
|
|
// Both non-stopped shapes matter and they are different states: a server that is
|
|
// UP (Ready) plainly holds the volume, but so does one that is merely coming up
|
|
// (desiredState=Running, not yet Ready) — the gate keys on intent as well as
|
|
// readiness, exactly as the backup/restore gates do.
|
|
states := []struct {
|
|
name string
|
|
ready bool
|
|
desiredState v1alpha1.DesiredState
|
|
}{
|
|
{"running", true, v1alpha1.DesiredRunning},
|
|
{"starting", false, v1alpha1.DesiredRunning},
|
|
}
|
|
|
|
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()
|
|
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)
|
|
if w.Code != http.StatusConflict || decodeErr(t, w) != "not_stopped" {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if files.calls != 0 {
|
|
t.Fatal("a running server holds the RWO world PVC — the file Job must never be created")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFileEditorWorldVolumeGate pins the second physical gate: a server with no
|
|
// 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
|
|
// the backup/restore faces use.
|
|
func TestFileEditorWorldVolumeGate(t *testing.T) {
|
|
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
|
|
|
|
routes := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{"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="}`},
|
|
}
|
|
|
|
for _, rt := range routes {
|
|
t.Run(rt.name+" without a world volume -> 409 no_world_volume", func(t *testing.T) {
|
|
api, _, cl, files := mkFiles()
|
|
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)
|
|
if w.Code != http.StatusConflict || decodeErr(t, w) != "no_world_volume" {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if files.calls != 0 {
|
|
t.Fatal("no claim to mount — the file Job must never be created")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// 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"}
|
|
stranger := &Principal{UserID: "stranger", Email: "[email protected]", Role: "user"}
|
|
admin := &Principal{UserID: "admin1", Email: "[email protected]", Role: "admin", ViaAdminAccess: true}
|
|
|
|
routes := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{"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
|
|
}
|
|
|
|
for _, rt := range routes {
|
|
t.Run(rt.name+": non-owner -> 403, executor untouched", func(t *testing.T) {
|
|
api, _, _, files := mkFiles()
|
|
api.External = staticExternal{p: stranger}
|
|
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if files.calls != 0 {
|
|
t.Fatal("a forbidden caller must not reach the file executor")
|
|
}
|
|
})
|
|
|
|
t.Run(rt.name+": owner -> allowed", func(t *testing.T) {
|
|
api, _, _, files := mkFiles()
|
|
api.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if files.calls != 1 || files.gotServer != "survival" {
|
|
t.Fatalf("executor saw (calls=%d, server=%q)", files.calls, files.gotServer)
|
|
}
|
|
})
|
|
|
|
t.Run(rt.name+": admin on someone else's server -> allowed", func(t *testing.T) {
|
|
api, repo, _, files := mkFiles()
|
|
repo.byName["survival"].OwnerID = "someone-else"
|
|
api.External = staticExternal{p: admin}
|
|
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if files.calls != 1 {
|
|
t.Fatal("admin should reach the executor")
|
|
}
|
|
})
|
|
|
|
t.Run(rt.name+": unowned server -> 403 for a plain user", func(t *testing.T) {
|
|
api, repo, _, _ := mkFiles()
|
|
repo.byName["survival"].OwnerID = "" // released world
|
|
api.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(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.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), rt.method,
|
|
strings.Replace(rt.path, "survival", "missing", 1), rt.body, hdrFor(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.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), rt.method,
|
|
strings.Replace(rt.path, "survival", "X", 1), rt.body, hdrFor(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.Files = nil
|
|
api.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
|
|
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFileEditorHandlers covers the per-route behaviour the shared gate does not:
|
|
// what is passed through to the executor and what comes back.
|
|
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()
|
|
files.entries = []fileedit.Entry{{Name: "paper.yml", Size: 12}, {Name: "sub", IsDir: true}}
|
|
files.truncated = true
|
|
api.External = staticExternal{p: owner}
|
|
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files?path=config", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Path string `json:"path"`
|
|
Entries []fileedit.Entry `json:"entries"`
|
|
Truncated bool `json:"truncated"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
|
|
}
|
|
if files.gotPath != "config" {
|
|
t.Fatalf("executor saw path %q, want the query value verbatim", files.gotPath)
|
|
}
|
|
if resp.Path != "config" || len(resp.Entries) != 2 || !resp.Truncated {
|
|
t.Fatalf("unexpected response %+v", resp)
|
|
}
|
|
})
|
|
|
|
t.Run("list without a path lists the world root", func(t *testing.T) {
|
|
api, _, _, files := mkFiles()
|
|
api.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if files.gotPath != "" {
|
|
t.Fatalf("path = %q, want empty (the root)", files.gotPath)
|
|
}
|
|
})
|
|
|
|
t.Run("read returns base64 content and its hash", func(t *testing.T) {
|
|
api, _, _, files := mkFiles()
|
|
files.content = []byte("motd=hello\n")
|
|
files.sum = testSum
|
|
api.External = staticExternal{p: owner}
|
|
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=server.properties", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Path string `json:"path"`
|
|
Content []byte `json:"content"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("body not JSON: %v", err)
|
|
}
|
|
if resp.Path != "server.properties" || string(resp.Content) != "motd=hello\n" || resp.SHA256 != testSum {
|
|
t.Fatalf("unexpected response %+v (%q)", resp, resp.Content)
|
|
}
|
|
})
|
|
|
|
t.Run("read without a path -> 400", func(t *testing.T) {
|
|
api, _, _, files := mkFiles()
|
|
api.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file", "", nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400", w.Code)
|
|
}
|
|
if files.calls != 0 {
|
|
t.Fatal("a pathless read must not reach the executor")
|
|
}
|
|
})
|
|
|
|
t.Run("write decodes content, audits, and answers 200", func(t *testing.T) {
|
|
api, repo, _, files := mkFiles()
|
|
api.External = staticExternal{p: owner}
|
|
|
|
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
|
|
`{"content":"bW90ZD1jaGFuZ2VkCg=="}`, jsonHeader)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if string(files.gotContent) != "motd=changed\n" {
|
|
t.Fatalf("executor got content %q, want the decoded bytes", files.gotContent)
|
|
}
|
|
if len(repo.audits) != 1 || repo.audits[0].Action != "file.write" ||
|
|
repo.audits[0].Actor != "[email protected]" {
|
|
t.Fatalf("write not audited as expected: %+v", repo.audits)
|
|
}
|
|
})
|
|
|
|
t.Run("write passes the expected hash through and returns the new one", func(t *testing.T) {
|
|
api, _, _, files := mkFiles()
|
|
files.sum = strings.Repeat("b", 64)
|
|
api.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
|
|
`{"content":"aGk=","expect_sha256":"`+testSum+`"}`, jsonHeader)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if files.gotExpect != testSum {
|
|
t.Fatalf("executor got expect %q, want %q", files.gotExpect, testSum)
|
|
}
|
|
var resp struct {
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil || resp.SHA256 != files.sum {
|
|
t.Fatalf("response sha256 = %q (%v), want %q", resp.SHA256, err, files.sum)
|
|
}
|
|
})
|
|
|
|
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.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",
|
|
string(body), jsonHeader)
|
|
if w.Code != http.StatusBadRequest || files.calls != 0 {
|
|
t.Fatalf("expect %q: code = %d calls = %d, want 400 and no Job", bad, w.Code, files.calls)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("a stale write -> 409 file_changed, not audited", func(t *testing.T) {
|
|
api, repo, _, files := mkFiles()
|
|
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",
|
|
`{"content":"aGk=","expect_sha256":"`+testSum+`"}`, jsonHeader)
|
|
if w.Code != http.StatusConflict || decodeErr(t, w) != "file_changed" {
|
|
t.Fatalf("code = %d body %s, want 409 file_changed", w.Code, w.Body.String())
|
|
}
|
|
if len(repo.audits) != 0 {
|
|
t.Fatalf("a refused write was audited: %+v", repo.audits)
|
|
}
|
|
})
|
|
|
|
t.Run("reads are not audited", func(t *testing.T) {
|
|
api, repo, _, _ := mkFiles()
|
|
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)
|
|
if len(repo.audits) != 0 {
|
|
t.Fatalf("reads should not write audit rows: %+v", repo.audits)
|
|
}
|
|
})
|
|
|
|
t.Run("oversized write -> 413 before the executor", func(t *testing.T) {
|
|
api, _, _, files := mkFiles()
|
|
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))})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=big.txt",
|
|
string(body), jsonHeader)
|
|
if w.Code != http.StatusRequestEntityTooLarge || decodeErr(t, w) != "too_large" {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if files.calls != 0 {
|
|
t.Fatal("an oversized write must be refused before a Job is rendered")
|
|
}
|
|
})
|
|
|
|
// A body that omits "content" must be refused, not treated as empty. Before
|
|
// Content became a *[]byte, {} decoded to nil and travelled all the way to
|
|
// O_TRUNC — so a client serialisation bug answered 200 while zeroing the very
|
|
// config the caller opened the editor to repair. An explicit "" stays legal,
|
|
// 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.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
|
|
body, jsonHeader)
|
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
|
|
t.Fatalf("body %s: code = %d, want 400 bad_request (%s)", body, w.Code, w.Body.String())
|
|
}
|
|
if files.calls != 0 {
|
|
t.Fatalf("body %s: reached the executor; an omitted content field must never truncate", body)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("an explicit empty content is a legitimate truncate", func(t *testing.T) {
|
|
api, _, _, files := mkFiles()
|
|
api.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
|
|
`{"content":""}`, jsonHeader)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if files.calls != 1 || len(files.gotContent) != 0 {
|
|
t.Fatalf("calls = %d, content = %d bytes; want one call writing 0 bytes",
|
|
files.calls, len(files.gotContent))
|
|
}
|
|
})
|
|
|
|
t.Run("a write at exactly the limit is allowed", func(t *testing.T) {
|
|
api, _, _, files := mkFiles()
|
|
api.External = staticExternal{p: owner}
|
|
body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes))})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=big.txt",
|
|
string(body), jsonHeader)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("code = %d, want 200 — the limit is inclusive (%s)", w.Code, w.Body.String())
|
|
}
|
|
if len(files.gotContent) != fileedit.MaxWriteBytes {
|
|
t.Fatalf("executor got %d bytes, want %d", len(files.gotContent), fileedit.MaxWriteBytes)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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
|
|
// be granted.
|
|
func TestFileEditorErrorMapping(t *testing.T) {
|
|
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
|
|
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
wantCode int
|
|
wantErr string
|
|
}{
|
|
{"escaping path", fmt.Errorf("%w: nope", fileedit.ErrBadPath), http.StatusBadRequest, "bad_path"},
|
|
{"missing file", fmt.Errorf("%w: nope", fileedit.ErrNotFound), http.StatusNotFound, "not_found"},
|
|
{"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"},
|
|
{"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()
|
|
files.err = tc.err
|
|
api.External = staticExternal{p: owner}
|
|
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil)
|
|
if w.Code != tc.wantCode || decodeErr(t, w) != tc.wantErr {
|
|
t.Fatalf("code = %d body %s, want %d/%s", w.Code, w.Body.String(), tc.wantCode, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("an unrecognised executor failure -> 500", func(t *testing.T) {
|
|
api, _, _, files := mkFiles()
|
|
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)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("code = %d, want 500 (%s)", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// bytesPtr builds the *[]byte writeFileRequest.Content wants. The pointer is what
|
|
// lets an omitted field be distinguished from an empty one; see the type's comment.
|
|
func bytesPtr(b []byte) *[]byte { return &b }
|