The dual-faced felis-api: internal (service) and external (public/app/admin) routes behind a Zero-Trust guard. Includes the access domain (whitelist, ban, and LuckPerms permission/group control over the owner-gated RCON path), the modpack submission endpoints, and the admin-tier SysAdmin fleet read. Structured access fields are charset-validated before assembly so no field can splice a second RCON command.
177 lines
6.6 KiB
Go
177 lines
6.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
|
)
|
|
|
|
// The internal-face wake (spec §9.1, §14) is what velocity drives for
|
|
// domain-autostart: service-token auth, the joining player identified by their
|
|
// verified online-mode UUID rather than a web Principal. These exercise the same
|
|
// autostartPolicy gate as the external wake but keyed by UUID, plus the shared
|
|
// cooldown and the phase reported back so velocity can decide whether to wait.
|
|
|
|
const wakeUUID = "11111111-2222-3333-4444-555555555555"
|
|
|
|
func newInternalWakeAPI(policy string) (*API, *fakeCluster) {
|
|
repo := newFakeRepo()
|
|
cl := newFakeCluster()
|
|
cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped", AutostartPolicy: policy}
|
|
return newTestAPI(repo, cl), cl
|
|
}
|
|
|
|
func internalWake(api *API, body string) *httptest.ResponseRecorder {
|
|
return do(api.InternalHandler(), "POST", "/api/v1/internal/servers/survival/wake", body, nil)
|
|
}
|
|
|
|
func TestInternalWakeAutostartGate(t *testing.T) {
|
|
body := `{"mc_uuid":"` + wakeUUID + `"}`
|
|
|
|
t.Run("public: any UUID wakes and gets phase back", func(t *testing.T) {
|
|
api, cl := newInternalWakeAPI("public")
|
|
w := internalWake(api, body)
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if cl.desired["survival"] != v1alpha1.DesiredRunning {
|
|
t.Fatalf("desiredState = %q, want Running", cl.desired["survival"])
|
|
}
|
|
// velocity reads phase/ready to decide whether to hold the player.
|
|
var got map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
|
|
}
|
|
if got["phase"] != "Stopped" {
|
|
t.Fatalf("phase = %v, want Stopped", got["phase"])
|
|
}
|
|
if got["ready"] != false {
|
|
t.Fatalf("ready = %v, want false", got["ready"])
|
|
}
|
|
})
|
|
|
|
t.Run("missing mc_uuid is rejected", func(t *testing.T) {
|
|
api, cl := newInternalWakeAPI("public")
|
|
w := internalWake(api, `{}`)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("code = %d, want 400", w.Code)
|
|
}
|
|
if _, set := cl.desired["survival"]; set {
|
|
t.Fatal("desiredState must not change when the UUID is missing")
|
|
}
|
|
})
|
|
|
|
t.Run("ownerOnly: unlinked UUID forbidden", func(t *testing.T) {
|
|
api, cl := newInternalWakeAPI("ownerOnly")
|
|
api.Repo.(*fakeRepo).byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
|
w := internalWake(api, body)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403", w.Code)
|
|
}
|
|
if _, set := cl.desired["survival"]; set {
|
|
t.Fatal("desiredState must not change on a forbidden wake")
|
|
}
|
|
})
|
|
|
|
t.Run("ownerOnly: owner's linked UUID wakes", func(t *testing.T) {
|
|
api, cl := newInternalWakeAPI("ownerOnly")
|
|
repo := api.Repo.(*fakeRepo)
|
|
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
|
repo.links[wakeUUID] = "owner1" // account_links: this UUID belongs to owner1
|
|
if w := internalWake(api, body); w.Code != http.StatusAccepted {
|
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if cl.desired["survival"] != v1alpha1.DesiredRunning {
|
|
t.Fatalf("desiredState = %q, want Running", cl.desired["survival"])
|
|
}
|
|
})
|
|
|
|
t.Run("ownerOnly: a different linked user is still forbidden", func(t *testing.T) {
|
|
api, _ := newInternalWakeAPI("ownerOnly")
|
|
repo := api.Repo.(*fakeRepo)
|
|
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
|
repo.links[wakeUUID] = "someone-else"
|
|
if w := internalWake(api, body); w.Code != http.StatusForbidden {
|
|
t.Fatalf("code = %d, want 403", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("allowlist: only a listed UUID wakes", func(t *testing.T) {
|
|
api, _ := newInternalWakeAPI("allowlist")
|
|
repo := api.Repo.(*fakeRepo)
|
|
repo.allowUUID["survival"] = map[string]bool{wakeUUID: true}
|
|
if w := internalWake(api, body); w.Code != http.StatusAccepted {
|
|
t.Fatalf("listed UUID: code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
api2, _ := newInternalWakeAPI("allowlist") // a different, unlisted UUID
|
|
other := `{"mc_uuid":"99999999-0000-0000-0000-000000000000"}`
|
|
if w := internalWake(api2, other); w.Code != http.StatusForbidden {
|
|
t.Fatalf("unlisted UUID: code = %d, want 403", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("allowlist: owner bypasses the list even when not on it", func(t *testing.T) {
|
|
api, cl := newInternalWakeAPI("allowlist")
|
|
repo := api.Repo.(*fakeRepo)
|
|
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
|
|
repo.links[wakeUUID] = "owner1" // owner, but allowUUID is empty
|
|
if w := internalWake(api, body); w.Code != http.StatusAccepted {
|
|
t.Fatalf("owner: code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if cl.desired["survival"] != v1alpha1.DesiredRunning {
|
|
t.Fatalf("desiredState = %q, want Running", cl.desired["survival"])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInternalWakeCooldownIsShared(t *testing.T) {
|
|
api, _ := newInternalWakeAPI("public")
|
|
api.WakeCooldown = time.Minute
|
|
body := `{"mc_uuid":"` + wakeUUID + `"}`
|
|
|
|
if w := internalWake(api, body); w.Code != http.StatusAccepted {
|
|
t.Fatalf("first wake code = %d", w.Code)
|
|
}
|
|
// clock is frozen, so the second wake is inside the cooldown window; velocity
|
|
// reads this 429 as "already waking, keep waiting", not a failure.
|
|
if w := internalWake(api, body); w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("second wake code = %d, want 429", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestInternalWakeRunningCap proves the §9.1 cap is shared by the velocity-driven
|
|
// internal wake, not only the web wake: with one slot and another server already
|
|
// desired-Running, a join-triggered wake of a stopped server is held with 503
|
|
// at_capacity and leaves desiredState untouched. velocity reads this as "cluster
|
|
// full, hold the player", distinct from the 429 cooldown's "already waking".
|
|
func TestInternalWakeRunningCap(t *testing.T) {
|
|
api, cl := newInternalWakeAPI("public")
|
|
api.MaxRunningServers = 1
|
|
cl.list = []ServerInfo{{Name: "other", DesiredState: string(v1alpha1.DesiredRunning)}}
|
|
body := `{"mc_uuid":"` + wakeUUID + `"}`
|
|
|
|
w := internalWake(api, body)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("code = %d, want 503 at_capacity (body %s)", w.Code, w.Body.String())
|
|
}
|
|
if code := decodeErr(t, w); code != "at_capacity" {
|
|
t.Fatalf("error code = %q, want at_capacity", code)
|
|
}
|
|
if _, set := cl.desired["survival"]; set {
|
|
t.Fatal("desiredState must not change when the cluster is at capacity")
|
|
}
|
|
}
|
|
|
|
func TestInternalStatusServedOnInternalFace(t *testing.T) {
|
|
api, _ := newInternalWakeAPI("public")
|
|
w := do(api.InternalHandler(), "GET", "/api/v1/internal/servers/survival/status", "", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d body %s", w.Code, w.Body.String())
|
|
}
|
|
}
|