myxce/src/frontend/apiserver/controllers/jobs_test.go
Rongsong Shen e00526f9f5
fix(apiserver): validate named and array submissions like any other (bug-1953)
preCheckJobRequest() returned from inside its job-name branch, so every
named or array job skipped the description, project and resreq checks,
resreq validation and the shell-limit conversion that follow. The name now
only sets the job count and array flag, and every submission runs the rest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 08:59:34 +00:00

305 lines
12 KiB
Go

package controllers
import (
"apiserver/backend"
"apiserver/models"
"apiserver/store"
"apiserver/utils"
"errors"
"strings"
"testing"
"go.uber.org/zap"
)
func resetJobSubmitState() {
store.InitStores()
utils.Log = zap.NewNop()
}
// TestPreCheckJobRequest_LostAndFound verifies that submitting to the
// "lost_and_found" queue is rejected with a clear error message.
func TestPreCheckJobRequest_LostAndFound(t *testing.T) {
resetJobSubmitState()
req := &models.JobSubmitRequest{
QueueName: "lost_and_found",
}
_, _, err := preCheckJobRequest(req)
if err == nil {
t.Fatal("expected error when submitting to lost_and_found queue, got nil")
}
if !strings.Contains(err.Error(), "lost_and_found") {
t.Errorf("error message should mention 'lost_and_found', got: %v", err)
}
if !strings.Contains(err.Error(), "internal recovery queue") {
t.Errorf("error message should mention 'internal recovery queue', got: %v", err)
}
}
// TestJobsListFieldValidation_UsesJobInfoNotSubmitRequest is a regression
// guard for bug-1674 (gitea #41): GetJobsInfo (jobs.go:553) and GetJobByIDs
// (jobs.go:901) validate a caller's requested `-o`/`-l` field list against
// utils.GetSupportedFields(models.JobInfo{}) merged with
// models.JobAdditionalFields{} -- not models.JobSubmitRequest{}, the
// job-submission model. Response-only fields like pending_reason/start_time/
// end_time exist on JobInfo but not on JobSubmitRequest; validating against
// the wrong struct made `-l` (which requests these fields) fail outright with
// "unsupported query field" on every invocation, regardless of which such
// field Go's randomized map iteration hit first.
//
// allXcecliJobFields mirrors xcecli/models/jobs.go's JobFieldsMap value set
// verbatim (xcecli is a separate Go module -- see src/frontend/CLAUDE.md --
// so it cannot be imported directly here). Every value in that map is a
// field xcecli's `-o`/`-l` can actually send the apiserver; this test proves
// the combined JobInfo+JobAdditionalFields supported-fields list -- what
// both fixed call sites now validate against -- covers every one of them.
var allXcecliJobFields = []string{
"job_id", "submit_user", "job_status", "queue_name", "command",
"job_name", "submit_time", "from_host", "exec_hosts", "core_limit",
"mem_limit", "pids", "pgids", "project_name", "start_time", "end_time",
"cwd", "cpu_time", "resreq", "job_description", "launch_type",
"num_processors", "interactive_job_url", "pending_reason",
}
func TestJobsListFieldValidation_UsesJobInfoNotSubmitRequest(t *testing.T) {
supportedFields := utils.GetSupportedFields(models.JobInfo{})
utils.AddFieldsToSupportedList(&supportedFields, models.JobAdditionalFields{})
for _, field := range allXcecliJobFields {
if !utils.ContainsField(supportedFields, field) {
t.Errorf("GetSupportedFields(models.JobInfo{}) + JobAdditionalFields is missing %q -- xcecli's `-o`/`-l` can request this field (see xcecli/models/jobs.go's JobFieldsMap) and it must validate successfully", field)
}
}
// Documents exactly why validating against JobSubmitRequest broke:
// response-only fields like pending_reason don't exist there at all.
submitRequestFields := utils.GetSupportedFields(models.JobSubmitRequest{})
for _, field := range []string{"pending_reason", "start_time", "end_time"} {
if utils.ContainsField(submitRequestFields, field) {
t.Errorf("unexpected: models.JobSubmitRequest{} contains %q -- this test's premise (that JobSubmitRequest lacks response-only fields) no longer holds", field)
}
}
}
// TestPreCheckJobRequest_NormalQueue verifies that a normal (non-reserved)
// queue name does not trigger the reserved-name rejection.
func TestPreCheckJobRequest_NormalQueue(t *testing.T) {
resetJobSubmitState()
// Seed a valid open queue in the store.
store.Queues.Put("normal", models.QueueInfo{
QueueName: "normal",
QueueStatus: 0, // open
})
req := &models.JobSubmitRequest{
QueueName: "normal",
}
_, _, err := preCheckJobRequest(req)
// Should not be rejected for the reserved-name check; may fail for other
// reasons (e.g. missing JobName validation), but not with our new error.
if err != nil && strings.Contains(err.Error(), "internal recovery queue") {
t.Errorf("normal queue should not be rejected with reserved-name error: %v", err)
}
}
// TestPreCheckJobRequest_EmptyQueue verifies that an empty queue name is
// allowed (defaults to "normal") and is not confused with reserved names.
func TestPreCheckJobRequest_EmptyQueue(t *testing.T) {
resetJobSubmitState()
req := &models.JobSubmitRequest{
QueueName: "",
}
// Empty queue name sets default — should not trigger reserved-name error.
_, _, err := preCheckJobRequest(req)
if err != nil && strings.Contains(err.Error(), "internal recovery queue") {
t.Errorf("empty queue should not trigger reserved-name error: %v", err)
}
}
// TestValidateJobControl_HandlesRealJobInfoTypes is a regression guard for
// bug-1742 (gitea #45): models.JobInfo.JobStatus and .ArrayIdx are plain Go
// `int` (see the JobInfo struct in models/jobs.go), but validateJobControl
// asserted them as `int64` -- a mismatch invisible to any test that
// hand-built its utils.OrderMap with int64 literals instead of going through
// the same store.StructToOrderMap() reflection path production actually
// uses. That mismatch made every job-control call panic with "interface
// conversion: interface {} is int, not int64", surfaced to xcecli as an
// empty `failed: []` (Gin's Recovery() middleware swallows the panic).
func TestValidateJobControl_HandlesRealJobInfoTypes(t *testing.T) {
resetJobSubmitState()
jobInfo := models.JobInfo{
Unique_JobID: 73932643848159233,
JobID: 1788186846,
ArrayIdx: 1,
SubmitUser: "admin",
QueueName: "normal",
JobStatus: models.JOBSTAT["RUN"],
}
orderMap := store.StructToOrderMap(jobInfo)
successJobs, failedJobs := validateJobControl(orderMap, "kill", "")
if len(successJobs) != 1 || len(failedJobs) != 0 {
t.Fatalf("expected exactly one success job for a killable RUN job, got successJobs=%v failedJobs=%v", successJobs, failedJobs)
}
if got := successJobs[0]["array_idx"]; got != 1 {
t.Errorf("array_idx = %v (%T), want 1 (int)", got, got)
}
}
// TestValidateJobControl_RejectsNonKillableStatus verifies a job in a
// terminal state is reported as a failure (not silently dropped, and
// without panicking) -- this path also reads array_idx before returning.
func TestValidateJobControl_RejectsNonKillableStatus(t *testing.T) {
resetJobSubmitState()
jobInfo := models.JobInfo{
Unique_JobID: 1,
JobID: 1,
ArrayIdx: 0,
JobStatus: models.JOBSTAT["DONE"],
}
orderMap := store.StructToOrderMap(jobInfo)
successJobs, failedJobs := validateJobControl(orderMap, "kill", "")
if len(successJobs) != 0 || len(failedJobs) != 1 {
t.Fatalf("expected exactly one failed job for a DONE job, got successJobs=%v failedJobs=%v", successJobs, failedJobs)
}
}
// TestFoundJob_MatchesRealArrayIdxType is a regression guard for the same
// bug-1742 type mismatch inside foundJob's array-index lookup path.
func TestFoundJob_MatchesRealArrayIdxType(t *testing.T) {
resetJobSubmitState()
// an array element: its unique_job_id carries the array marking
const key = uint64(2)<<40 | uint64(1)<<20
elem := backend.MakeElementID(key, 1)
jobInfo := models.JobInfo{
Unique_JobID: int64(elem),
JobID: int64(key),
ArrayIdx: 1,
}
orderMap := store.StructToOrderMap(jobInfo)
found, err := foundJob(int64(key), 1, true, []utils.OrderMap{orderMap})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(found) != 1 {
t.Fatalf("expected to find 1 job, got %d", len(found))
}
}
// TestFoundJob_ExplicitIndex (5.4, 5.6, 8.2): an explicit element number
// matches only array elements with that number -- never an ordinary job with
// array_idx 0 or the whole array -- while no element part matches every row
// with that job_id.
func TestFoundJob_ExplicitIndex(t *testing.T) {
const key = uint64(2)<<40 | uint64(1)<<20
row := func(uid uint64, idx int) utils.OrderMap {
return store.StructToOrderMap(models.JobInfo{Unique_JobID: int64(uid), JobID: int64(key), ArrayIdx: idx})
}
ordinary := row(key, 0) // an ordinary job sharing the number
e1 := row(backend.MakeElementID(key, 1), 1)
e2 := row(backend.MakeElementID(key, 2), 2)
withZero := []utils.OrderMap{ordinary, e1, e2, row(backend.MakeElementID(key, 0), 0)}
noZero := []utils.OrderMap{ordinary, e1, e2}
uids := func(rows []utils.OrderMap) []int64 {
out := []int64{}
for _, r := range rows {
out = append(out, r.Data["unique_job_id"].(int64))
}
return out
}
if got, err := foundJob(int64(key), 0, true, noZero); err == nil {
t.Errorf("[0] without element 0 matched %v, want not found", uids(got))
}
got, err := foundJob(int64(key), 0, true, withZero)
if err != nil || len(got) != 1 || uint64(got[0].Data["unique_job_id"].(int64)) != backend.MakeElementID(key, 0) {
t.Errorf("[0] with a pre-upgrade element 0 = %v, %v; want that element alone", uids(got), err)
}
got, err = foundJob(int64(key), 2, true, noZero)
if err != nil || len(got) != 1 || uint64(got[0].Data["unique_job_id"].(int64)) != backend.MakeElementID(key, 2) {
t.Errorf("[2] = %v, %v; want element 2 alone", uids(got), err)
}
if got, err := foundJob(int64(key), 3, true, noZero); err == nil {
t.Errorf("[3] matched %v, want not found", uids(got))
}
got, err = foundJob(int64(key), 0, false, noZero)
if err != nil || len(got) != 3 {
t.Errorf("bare = %v, %v; want every row with that job_id", uids(got), err)
}
}
// TestPreCheckJobRequest_RejectsMalformedResreq (bug-1744): a resource request
// the syntax check rejects must fail the submission instead of being accepted
// and later silently dropped by the scheduler.
func TestPreCheckJobRequest_RejectsMalformedResreq(t *testing.T) {
resetJobSubmitState()
store.Queues.Put("normal", models.QueueInfo{QueueName: "normal"})
var checked string
orig := validateResreqSyntax
validateResreqSyntax = func(s string) error {
checked = s
if strings.Contains(s, "100000000a0") {
return errors.New("invalid rusage in resource request")
}
return nil
}
defer func() { validateResreqSyntax = orig }()
req := &models.JobSubmitRequest{QueueName: "normal",
Resreq: "rusage[mem=100000000a0]"}
_, _, err := preCheckJobRequest(req)
if checked != "rusage[mem=100000000a0]" {
t.Fatalf("resreq was not syntax-checked (checked %q)", checked)
}
if err == nil || !strings.Contains(err.Error(), "invalid rusage") {
t.Fatalf("malformed resreq must be rejected, got err=%v", err)
}
}
// bug-1953: a job name (plain or array) must not skip the checks and
// conversions every other submission gets.
func TestPreCheckJobRequest_NamedJobsGetFullValidation(t *testing.T) {
resetJobSubmitState()
origCfg := utils.JobYamlConfig
utils.JobYamlConfig = &utils.JobConfig{MaxArraySize: 1000}
t.Cleanup(func() { utils.JobYamlConfig = origCfg })
for _, name := range []string{"", "myjob", "arr[1-3]"} {
req := &models.JobSubmitRequest{JobName: name, Command: "sleep 1"}
req.ShellLimit.CPULimit = 60000
n, isArray, err := preCheckJobRequest(req)
if err != nil {
t.Fatalf("name %q: unexpected error %v", name, err)
}
if req.CPULimit.Seconds != 60 {
t.Errorf("name %q: cpu limit not converted: got %d s, want 60", name, req.CPULimit.Seconds)
}
if name == "arr[1-3]" && (n != 3 || !isArray || req.JobName != "arr") {
t.Errorf("array name: got n=%d isArray=%v name=%q, want 3 true \"arr\"", n, isArray, req.JobName)
}
if name == "myjob" && (n != 1 || isArray) {
t.Errorf("plain name: got n=%d isArray=%v, want 1 false", n, isArray)
}
bad := &models.JobSubmitRequest{JobName: name, Command: "sleep 1",
ProjectName: strings.Repeat("p", 512)}
if _, _, err := preCheckJobRequest(bad); err == nil {
t.Errorf("name %q: an over-long project name was accepted", name)
}
}
}