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) } } }