123 lines
4.3 KiB
Go
123 lines
4.3 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"felis.lolicon.best/internal/nodecontrol"
|
|
)
|
|
|
|
type fakeNodeControl struct {
|
|
tasks []nodecontrol.Task
|
|
calls int
|
|
err error
|
|
request nodecontrol.Request
|
|
actor string
|
|
}
|
|
|
|
func (f *fakeNodeControl) List(context.Context) ([]nodecontrol.Task, error) { return f.tasks, f.err }
|
|
func (f *fakeNodeControl) Get(context.Context, string) (nodecontrol.Task, error) {
|
|
if f.err != nil {
|
|
return nodecontrol.Task{}, f.err
|
|
}
|
|
if len(f.tasks) == 0 {
|
|
return nodecontrol.Task{}, nodecontrol.ErrNotFound
|
|
}
|
|
return f.tasks[0], nil
|
|
}
|
|
func (f *fakeNodeControl) Start(_ context.Context, r nodecontrol.Request, actor string) (nodecontrol.Task, error) {
|
|
f.calls++
|
|
f.request = r
|
|
f.actor = actor
|
|
return nodecontrol.Task{ID: "task", Request: r, State: "running"}, f.err
|
|
}
|
|
|
|
const nodeTaskPath = "/api/v1/settings/node-control/tasks"
|
|
const nodeTaskBody = `{"action":"approve","name":"worker-01","sshTarget":"worker-01","confirmMaintenance":true}`
|
|
|
|
func TestNodeControlOwnerBoundaryAndRetries(t *testing.T) {
|
|
for _, role := range []string{"user", "admin", "owner"} {
|
|
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
a.External = staticExternal{p: &Principal{UserID: "actor", Role: role, ViaAdminAccess: role != "user"}}
|
|
control := &fakeNodeControl{tasks: []nodecontrol.Task{{ID: "task", State: "failed", Request: nodecontrol.Request{Action: "approve", Name: "worker-01", SSHTarget: "worker-01", ConfirmMaintenance: true}}}}
|
|
a.NodeControl = control
|
|
for _, tc := range []struct {
|
|
method, path, body string
|
|
want int
|
|
}{{"GET", "/api/v1/settings/node-control", "", 200}, {"GET", nodeTaskPath + "/task", "", 200}, {"POST", nodeTaskPath, nodeTaskBody, 202}, {"POST", nodeTaskPath + "/task/retry", "", 202}} {
|
|
w := do(a.ExternalHandler(), tc.method, tc.path, tc.body, jsonHeader)
|
|
want := tc.want
|
|
if role != "owner" {
|
|
want = 403
|
|
}
|
|
if w.Code != want {
|
|
t.Fatalf("%s %s: %d %s", role, tc.path, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
if role != "owner" && control.calls != 0 {
|
|
t.Fatal("nonowner reached root operations")
|
|
}
|
|
if role == "owner" && (control.actor != "actor" || control.request.Name != "worker-01") {
|
|
t.Fatal("actor/scope not preserved")
|
|
}
|
|
}
|
|
}
|
|
func TestNodeControlErrorsAndStartGuard(t *testing.T) {
|
|
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
|
a.External = staticExternal{p: &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}}
|
|
control := &fakeNodeControl{}
|
|
a.NodeControl = control
|
|
w := do(a.ExternalHandler(), "POST", nodeTaskPath, `{"action":"approve","name":"x","sshTarget":"-exec","confirmMaintenance":true}`, jsonHeader)
|
|
if w.Code != 400 || control.calls != 0 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
for _, tc := range []struct {
|
|
err error
|
|
code int
|
|
}{{nodecontrol.ErrBusy, 409}, {errors.New("private host detail"), 503}} {
|
|
control.err = tc.err
|
|
w := do(a.ExternalHandler(), "POST", nodeTaskPath, nodeTaskBody, jsonHeader)
|
|
if w.Code != tc.code {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
}
|
|
guard := NodeMaintenanceGuard(control)
|
|
if guard(context.Background()) == nil {
|
|
t.Fatal("host failure permitted start")
|
|
}
|
|
control.err = nil
|
|
control.tasks = []nodecontrol.Task{{State: "running"}}
|
|
if guard(context.Background()) == nil {
|
|
t.Fatal("maintenance permitted start")
|
|
}
|
|
control.tasks[0].State = "failed"
|
|
if err := guard(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
control.tasks[0].State = "succeeded"
|
|
w = do(a.ExternalHandler(), "POST", nodeTaskPath+"/task/retry", "", jsonHeader)
|
|
if w.Code != 409 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNodeControlRequiresFreshOwnerProof(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
a := newTestAPI(repo, newFakeCluster())
|
|
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true, ViaSession: true}
|
|
repo.passkeyCreds["owner-key"] = PasskeyCredential{ID: "owner-key", UserID: "owner", UserVerified: true}
|
|
a.External = staticExternal{p: p}
|
|
control := &fakeNodeControl{}
|
|
a.NodeControl = control
|
|
w := do(a.ExternalHandler(), "POST", nodeTaskPath, nodeTaskBody, jsonHeader)
|
|
if w.Code != 403 || decodeErr(t, w) != "reauth_required" || control.calls != 0 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
p.ReauthAt = a.now()
|
|
w = do(a.ExternalHandler(), "POST", nodeTaskPath, nodeTaskBody, jsonHeader)
|
|
if w.Code != 202 || control.calls != 1 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
}
|