// 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) } }) } // HTMX path: unauthenticated POST with HX-Request: true → 303 to /login. // Auth check fires before host lookup so the host ID doesn't need to exist. for _, path := range []string{ "/hosts/" + hostID + "/repo/prune", "/hosts/" + hostID + "/repo/check", "/hosts/" + hostID + "/repo/unlock", } { path := path t.Run("htmx"+path, func(t *testing.T) { t.Parallel() client := &stdhttp.Client{ CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error { return stdhttp.ErrUseLastResponse }, } req, _ := stdhttp.NewRequest("POST", url+path, nil) req.Header.Set("HX-Request", "true") res, err := client.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusSeeOther { t.Errorf("want 303, got %d", res.StatusCode) } if loc := res.Header.Get("Location"); loc != "/login" { t.Errorf("want Location=/login, got %q", loc) } }) } }