server: HTTP run-now for prune / check / unlock
Adds POST /api/hosts/{id}/repo/{prune,check,unlock} (and matching outer
routes for HTMX form posts). Prune pushes the admin-cred slot via
pushAdminCredsToAgent before dispatch and refuses with
admin_creds_required when the slot is not set. Check reads
check_subset_pct from host_repo_maintenance (overridable via ?subset=N,
clamped 0-100; non-numeric override falls back to DB value silently).
Unlock needs no admin creds. All three share the same wantsHTML/HX-Redirect
response split as the per-source-group run-now endpoint.
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
// repo_ops_test.go — integration tests for the repo run-now endpoints:
|
||||
// prune, check, unlock.
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// ----- helpers -------------------------------------------------------
|
||||
|
||||
// seedInitJob marks a fake init job done for the host so the auto-init
|
||||
// path doesn't fire and pollute the envelope sequence we're measuring.
|
||||
func seedInitJob(t *testing.T, st *store.Store, hostID string) {
|
||||
t.Helper()
|
||||
if err := st.CreateJob(context.Background(), store.Job{
|
||||
ID: ulid.Make().String(), HostID: hostID, Kind: "init",
|
||||
ActorKind: "system", CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed init job: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// setAdminCreds writes admin credentials for a host via the store directly.
|
||||
func setAdminCreds(t *testing.T, srv *Server, st *store.Store, hostID string) {
|
||||
t.Helper()
|
||||
enc, err := srv.encryptRepoCreds(repoCredsBlob{
|
||||
RepoURL: "rest:http://admin.example/h",
|
||||
RepoUsername: "admin",
|
||||
RepoPassword: "prune-pass",
|
||||
}, []byte("host:"+hostID+":admin"))
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt admin creds: %v", err)
|
||||
}
|
||||
if err := st.SetHostCredentials(context.Background(), hostID, store.CredKindAdmin, enc); err != nil {
|
||||
t.Fatalf("set admin creds: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// setMaintenanceSubset sets check_subset_pct for the host via the store.
|
||||
func setMaintenanceSubset(t *testing.T, st *store.Store, hostID string, pct int) {
|
||||
t.Helper()
|
||||
// Ensure the row exists first.
|
||||
if err := st.CreateDefaultRepoMaintenance(context.Background(), hostID); err != nil {
|
||||
t.Fatalf("seed maintenance: %v", err)
|
||||
}
|
||||
m, err := st.GetRepoMaintenance(context.Background(), hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("get maintenance: %v", err)
|
||||
}
|
||||
m.CheckSubsetPct = pct
|
||||
if err := st.UpdateRepoMaintenance(context.Background(), m); err != nil {
|
||||
t.Fatalf("update maintenance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// drainCommandRun reads envelopes until a command.run arrives, then
|
||||
// unmarshals and returns the payload.
|
||||
func drainCommandRun(t *testing.T, c *websocket.Conn) api.CommandRunPayload {
|
||||
t.Helper()
|
||||
env := drainUntil(t, c, api.MsgCommandRun)
|
||||
var p api.CommandRunPayload
|
||||
if err := env.UnmarshalPayload(&p); err != nil {
|
||||
t.Fatalf("unmarshal command.run: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ----- prune tests ---------------------------------------------------
|
||||
|
||||
// TestRunPruneRefusesWithoutAdminCreds: POST prune with no admin creds
|
||||
// set → 400, code admin_creds_required, no job row created.
|
||||
func TestRunPruneRefusesWithoutAdminCreds(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServer(t)
|
||||
hostID, token := enrolHostForWS(t, srv, st, "prune-no-admin")
|
||||
cookie := loginAsAdmin(t, st)
|
||||
seedInitJob(t, st, hostID)
|
||||
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "prune-no-admin")
|
||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||
|
||||
status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/prune", nil, cookie)
|
||||
if status != stdhttp.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d body=%+v", status, body)
|
||||
}
|
||||
if code, _ := body["code"].(string); code != "admin_creds_required" {
|
||||
t.Errorf("want code=admin_creds_required, got %+v", body)
|
||||
}
|
||||
|
||||
// No prune job row should have been persisted.
|
||||
var n int
|
||||
if err := st.DB().QueryRow(
|
||||
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'prune'`, hostID).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("unexpected prune job rows: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunPruneShipsConfigUpdateThenCommandRun: set admin creds, connect
|
||||
// host, POST prune. Assert envelope sequence: config.update(slot=admin)
|
||||
// → command.run(prune, RequiresAdminCreds=true). Assert job row persisted.
|
||||
func TestRunPruneShipsConfigUpdateThenCommandRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServer(t)
|
||||
hostID, token := enrolHostForWS(t, srv, st, "prune-happy")
|
||||
cookie := loginAsAdmin(t, st)
|
||||
setAdminCreds(t, srv, st, hostID)
|
||||
seedInitJob(t, st, hostID)
|
||||
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "prune-happy")
|
||||
// Drain on-hello burst (repo config.update + schedule.set).
|
||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||
|
||||
status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/prune", nil, cookie)
|
||||
if status != stdhttp.StatusAccepted {
|
||||
t.Fatalf("want 202, got %d body=%+v", status, body)
|
||||
}
|
||||
jobID, _ := body["job_id"].(string)
|
||||
if jobID == "" {
|
||||
t.Fatalf("no job_id in response: %+v", body)
|
||||
}
|
||||
|
||||
// Read the next two envelopes — must be config.update(slot=admin)
|
||||
// followed by command.run(prune).
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var sawAdminPush bool
|
||||
var prunePayload *api.CommandRunPayload
|
||||
for (prunePayload == nil) && time.Now().Before(deadline) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
|
||||
mt, raw, err := c.Read(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if mt != websocket.MessageText {
|
||||
continue
|
||||
}
|
||||
var env api.Envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
switch env.Type {
|
||||
case api.MsgConfigUpdate:
|
||||
var p api.ConfigUpdatePayload
|
||||
if err := env.UnmarshalPayload(&p); err == nil && p.Slot == "admin" {
|
||||
sawAdminPush = true
|
||||
}
|
||||
case api.MsgCommandRun:
|
||||
var p api.CommandRunPayload
|
||||
if err := env.UnmarshalPayload(&p); err == nil && p.Kind == api.JobPrune {
|
||||
copy := p
|
||||
prunePayload = ©
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !sawAdminPush {
|
||||
t.Error("expected config.update(slot=admin) before prune dispatch")
|
||||
}
|
||||
if prunePayload == nil {
|
||||
t.Fatal("timed out waiting for command.run(prune)")
|
||||
}
|
||||
if !prunePayload.RequiresAdminCreds {
|
||||
t.Error("prune command.run must have RequiresAdminCreds=true")
|
||||
}
|
||||
if prunePayload.JobID != jobID {
|
||||
t.Errorf("job_id mismatch: dispatch=%s run=%s", jobID, prunePayload.JobID)
|
||||
}
|
||||
|
||||
// Job row must be persisted.
|
||||
var n int
|
||||
if err := st.DB().QueryRow(
|
||||
`SELECT COUNT(*) FROM jobs WHERE id = ? AND host_id = ? AND kind = 'prune'`,
|
||||
jobID, hostID).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("prune job row count: want 1, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- check tests ---------------------------------------------------
|
||||
|
||||
// TestRunCheckUsesMaintenanceSubset: check_subset_pct=25 → Args==["25"].
|
||||
func TestRunCheckUsesMaintenanceSubset(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServer(t)
|
||||
hostID, token := enrolHostForWS(t, srv, st, "check-subset")
|
||||
cookie := loginAsAdmin(t, st)
|
||||
setMaintenanceSubset(t, st, hostID, 25)
|
||||
seedInitJob(t, st, hostID)
|
||||
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "check-subset")
|
||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||
|
||||
status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/check", nil, cookie)
|
||||
if status != stdhttp.StatusAccepted {
|
||||
t.Fatalf("want 202, got %d body=%+v", status, body)
|
||||
}
|
||||
|
||||
p := drainCommandRun(t, c)
|
||||
if p.Kind != api.JobCheck {
|
||||
t.Fatalf("kind: want check, got %s", p.Kind)
|
||||
}
|
||||
if len(p.Args) != 1 || p.Args[0] != "25" {
|
||||
t.Errorf("args: want [25], got %v", p.Args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCheckHonorsSubsetOverride: ?subset=10 overrides DB value of 25.
|
||||
func TestRunCheckHonorsSubsetOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServer(t)
|
||||
hostID, token := enrolHostForWS(t, srv, st, "check-override")
|
||||
cookie := loginAsAdmin(t, st)
|
||||
setMaintenanceSubset(t, st, hostID, 25)
|
||||
seedInitJob(t, st, hostID)
|
||||
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "check-override")
|
||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||
|
||||
status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/check?subset=10", nil, cookie)
|
||||
if status != stdhttp.StatusAccepted {
|
||||
t.Fatalf("want 202, got %d body=%+v", status, body)
|
||||
}
|
||||
|
||||
p := drainCommandRun(t, c)
|
||||
if len(p.Args) != 1 || p.Args[0] != "10" {
|
||||
t.Errorf("args: want [10], got %v", p.Args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCheckRejectsBadSubsetGracefully: ?subset=abc falls back to DB
|
||||
// value (not an error). strconv.Atoi failure silently ignored.
|
||||
func TestRunCheckRejectsBadSubsetGracefully(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServer(t)
|
||||
hostID, token := enrolHostForWS(t, srv, st, "check-badsubset")
|
||||
cookie := loginAsAdmin(t, st)
|
||||
setMaintenanceSubset(t, st, hostID, 30)
|
||||
seedInitJob(t, st, hostID)
|
||||
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "check-badsubset")
|
||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||
|
||||
status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/check?subset=abc", nil, cookie)
|
||||
if status != stdhttp.StatusAccepted {
|
||||
t.Fatalf("want 202 (bad subset falls back), got %d body=%+v", status, body)
|
||||
}
|
||||
|
||||
p := drainCommandRun(t, c)
|
||||
if len(p.Args) != 1 || p.Args[0] != strconv.Itoa(30) {
|
||||
t.Errorf("args: want [30], got %v", p.Args)
|
||||
}
|
||||
}
|
||||
|
||||
// ----- unlock tests --------------------------------------------------
|
||||
|
||||
// TestRunUnlockNeedsNoAdminCreds: no admin creds, POST unlock → 202.
|
||||
func TestRunUnlockNeedsNoAdminCreds(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, st := rawTestServer(t)
|
||||
hostID, token := enrolHostForWS(t, srv, st, "unlock-no-admin")
|
||||
cookie := loginAsAdmin(t, st)
|
||||
seedInitJob(t, st, hostID)
|
||||
|
||||
c := agentDial(t, srv, ts, hostID, token)
|
||||
sendHello(t, c, "unlock-no-admin")
|
||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||
|
||||
status, body := doJSON(t, ts.URL, "POST", "/api/hosts/"+hostID+"/repo/unlock", nil, cookie)
|
||||
if status != stdhttp.StatusAccepted {
|
||||
t.Fatalf("want 202, got %d body=%+v", status, body)
|
||||
}
|
||||
|
||||
p := drainCommandRun(t, c)
|
||||
if p.Kind != api.JobUnlock {
|
||||
t.Fatalf("kind: want unlock, got %s", p.Kind)
|
||||
}
|
||||
// RequiresAdminCreds must be false for unlock.
|
||||
if p.RequiresAdminCreds {
|
||||
t.Error("unlock must not set RequiresAdminCreds")
|
||||
}
|
||||
}
|
||||
|
||||
// ----- auth tests ----------------------------------------------------
|
||||
|
||||
// TestRunOpsRequireAuth: unauthenticated POST to each endpoint → 401.
|
||||
func TestRunOpsRequireAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, url, st := newTestServerWithHub(t)
|
||||
hostID := makeHost(t, st, "auth-host")
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/hosts/" + hostID + "/repo/prune",
|
||||
"/api/hosts/" + hostID + "/repo/check",
|
||||
"/api/hosts/" + hostID + "/repo/unlock",
|
||||
} {
|
||||
path := path
|
||||
t.Run(path, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req, _ := stdhttp.NewRequest("POST", url+path, nil)
|
||||
res, err := stdhttp.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != stdhttp.StatusUnauthorized {
|
||||
t.Errorf("want 401, got %d", res.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user