From e5de5b2bd680bac9e756194cf9201ba68bd98456 Mon Sep 17 00:00:00 2001 From: Boming Zhang Date: Sun, 2 Aug 2026 04:28:24 -0700 Subject: [PATCH] test: expand reliability regression coverage --- Makefile | 6 +- cmd/joj3/conf/conf.go | 3 + cmd/joj3/conf/conf_test.go | 57 ++++++++++++ cmd/joj3/main_test.go | 60 +++++++++++++ cmd/repo-health-checker/main.go | 74 ++++++++------- cmd/repo-health-checker/main_test.go | 101 +++++++++++++++++++++ internal/executor/local/executor.go | 6 +- internal/executor/local/executor_test.go | 104 ++++++++++++++++++++++ internal/executor/sandbox/convert_test.go | 54 ++++++++++- internal/executor/sandbox/grpc_test.go | 27 ++++++ internal/parser/keyword/parser_test.go | 38 ++++++++ internal/stage/fileerror.go | 2 +- internal/stage/json_test.go | 67 ++++++++++++++ pkg/healthcheck/author_test.go | 80 +++++++++++++++++ pkg/healthcheck/files_test.go | 67 ++++++++++++++ 15 files changed, 705 insertions(+), 41 deletions(-) create mode 100644 cmd/repo-health-checker/main_test.go create mode 100644 internal/executor/sandbox/grpc_test.go create mode 100644 internal/parser/keyword/parser_test.go create mode 100644 internal/stage/json_test.go create mode 100644 pkg/healthcheck/author_test.go create mode 100644 pkg/healthcheck/files_test.go diff --git a/Makefile b/Makefile index 6c65b39..a4d02bd 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ DATE := $(shell date +"%Y%m%d-%H%M%S") VERSION := $(COMMIT_HASH)-$(DATE) LDFLAGS := -s -w -X main.Version=$(VERSION) GOFLAGS := -trimpath -mod=readonly -buildvcs=false +COVERAGE_FILE ?= coverage.out all: build @@ -27,11 +28,12 @@ lint: prepare-test: git submodule update --init --remote -test: +test: build ./scripts/prepare_test_repos.sh $(TMP_DIR) # no clang-tidy-18 locally rm -rf $(TMP_DIR)/submodules/JOJ3-examples/examples/keyword/clangtidy - go test -count=1 -v ./... + go test -count=1 -v -coverpkg=./... -coverprofile=$(COVERAGE_FILE) ./... + go tool cover -func=$(COVERAGE_FILE) | tail -n 1 local-test: rm -rf $(TMP_DIR)/submodules/JOJ3-examples/examples/ diff --git a/cmd/joj3/conf/conf.go b/cmd/joj3/conf/conf.go index 7361f37..876a7d0 100644 --- a/cmd/joj3/conf/conf.go +++ b/cmd/joj3/conf/conf.go @@ -175,6 +175,9 @@ func GetConfPath(confRoot, confName, fallbackConfName, msg, tag string) ( hintValidScopes(confRoot, confName) } slog.Error("stat conf", "error", err) + if tag != "" { + return confPath, confStat, conventionalCommit, err + } // fallback to conf file in conf root on conf not exist confPath = filepath.Join(confRoot, fallbackConfName) slog.Info("fallback to conf", "path", confPath) diff --git a/cmd/joj3/conf/conf_test.go b/cmd/joj3/conf/conf_test.go index 58de74d..a7528c8 100644 --- a/cmd/joj3/conf/conf_test.go +++ b/cmd/joj3/conf/conf_test.go @@ -1,10 +1,67 @@ package conf import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" "reflect" + "strings" "testing" ) +func TestGetSHA256(t *testing.T) { + path := filepath.Join(t.TempDir(), "input") + content := []byte("joj3") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + wantSum := sha256.Sum256(content) + got, err := GetSHA256(path) + if err != nil || got != hex.EncodeToString(wantSum[:]) { + t.Fatalf("GetSHA256() = %q, %v", got, err) + } + if _, err := GetSHA256(path + ".missing"); !os.IsNotExist(err) { + t.Fatalf("missing GetSHA256() error = %v", err) + } +} + +func TestGetConfPath(t *testing.T) { + root := t.TempDir() + scopedDir := filepath.Join(root, "course") + if err := os.Mkdir(scopedDir, 0o700); err != nil { + t.Fatal(err) + } + for _, path := range []string{filepath.Join(scopedDir, "conf.json"), filepath.Join(root, "fallback.json")} { + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } + + got, _, commit, err := GetConfPath(root, "conf.json", "fallback.json", "test(course): run", "") + if err != nil || got != filepath.Join(scopedDir, "conf.json") || commit.Scope != "course" { + t.Fatalf("GetConfPath(scoped) = %q, %+v, %v", got, commit, err) + } + got, _, _, err = GetConfPath(root, "conf.json", "fallback.json", "not conventional", "") + if err != nil || got != filepath.Join(root, "fallback.json") { + t.Fatalf("GetConfPath(fallback) = %q, %v", got, err) + } + if _, _, err = parseMsg(root, "conf.json", "test(../escape): run", ""); err == nil || !strings.Contains(err.Error(), "invalid scope") { + t.Fatalf("parseMsg(traversal) error = %v", err) + } + got, _, commit, err = GetConfPath(root, "conf.json", "fallback.json", "ignored", "missing-tag") + if !os.IsNotExist(err) || got != filepath.Join(root, "missing-tag", "conf.json") || commit.Scope != "missing-tag" { + t.Fatalf("GetConfPath(tag) = %q, %+v, %v", got, commit, err) + } + if err := os.Remove(filepath.Join(root, "fallback.json")); err != nil { + t.Fatal(err) + } + got, _, _, err = GetConfPath(root, "conf.json", "fallback.json", "invalid", "") + if !os.IsNotExist(err) || got != filepath.Join(root, "fallback.json") { + t.Fatalf("GetConfPath(missing fallback) = %q, %v", got, err) + } +} + func TestParseConventionalCommit(t *testing.T) { tests := []struct { name string diff --git a/cmd/joj3/main_test.go b/cmd/joj3/main_test.go index edf1767..affafcc 100644 --- a/cmd/joj3/main_test.go +++ b/cmd/joj3/main_test.go @@ -87,7 +87,16 @@ func TestRun(t *testing.T) { t.Fatal(err) } for _, tt := range tests { + // The repo-health-checker package runs all healthcheck fixtures directly so + // their execution contributes to Go coverage. Keep one sandbox case here as + // an end-to-end binary/executor/parser smoke test. + if strings.HasPrefix(tt, "healthcheck/") && tt != "healthcheck/release" { + continue + } t.Run(tt, func(t *testing.T) { + if tt == "healthcheck/release" { + prepareLargeCopyInFixture(t, filepath.Join(root, tt)) + } t.Chdir(fmt.Sprintf("%s%s", root, tt)) os.Args = []string{"./joj3"} outputFile := "joj3_result.json" @@ -106,3 +115,54 @@ func TestRun(t *testing.T) { }) } } + +func prepareLargeCopyInFixture(t *testing.T, dir string) { + t.Helper() + const fileCount = 1001 + filesDir := filepath.Join(dir, "many-files") + if err := os.Mkdir(filesDir, 0o700); err != nil && !os.IsExist(err) { + t.Fatal(err) + } + for i := range fileCount { + path := filepath.Join(filesDir, fmt.Sprintf("%04d", i)) + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { _ = os.RemoveAll(filesDir) }) + + confPath := filepath.Join(dir, "conf.json") + original, err := os.ReadFile(confPath) + if err != nil { + t.Fatal(err) + } + var conf map[string]any + if err := json.Unmarshal(original, &conf); err != nil { + t.Fatal(err) + } + stages := conf["stages"].([]any) + executor := stages[0].(map[string]any)["executor"].(map[string]any) + with := executor["with"].(map[string]any) + command := with["default"].(map[string]any) + args := command["args"].([]any) + checkerArgs := make([]string, 0, len(args)) + for _, arg := range args { + checkerArgs = append(checkerArgs, fmt.Sprintf("%q", arg)) + } + command["args"] = []string{ + "/bin/sh", "-c", + fmt.Sprintf("test \"$(find many-files -type f | wc -l)\" -eq %d && exec %s", fileCount, strings.Join(checkerArgs, " ")), + } + patched, err := json.Marshal(conf) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(confPath, patched, 0o600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.WriteFile(confPath, original, 0o600); err != nil { + t.Errorf("restore conf: %v", err) + } + }) +} diff --git a/cmd/repo-health-checker/main.go b/cmd/repo-health-checker/main.go index 6108f07..ca29103 100644 --- a/cmd/repo-health-checker/main.go +++ b/cmd/repo-health-checker/main.go @@ -7,6 +7,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "log/slog" "os" @@ -15,8 +16,8 @@ import ( // parseMultiValueFlag parses a multi-value command-line flag and appends its values to the provided slice. // It registers a flag with the specified name and description, associating it with a multiStringValue receiver. -func parseMultiValueFlag(values *[]string, flagName, description string) { - flag.Var((*multiStringValue)(values), flagName, description) +func parseMultiValueFlag(flags *flag.FlagSet, values *[]string, flagName, description string) { + flags.Var((*multiStringValue)(values), flagName, description) } type multiStringValue []string @@ -39,36 +40,37 @@ func setupSlog() { slog.SetDefault(logger) } -var ( - rootDir string - repoSize float64 - checkFileNameList string - checkFileSumList string - metaFile []string - whitelistedChars string - allowedDomainList string - actorCsvPath string - showVersion *bool - Version string -) +var Version string -func init() { - showVersion = flag.Bool("version", false, "print current version") - flag.StringVar(&rootDir, "root", ".", "root dir for forbidden files check") - flag.Float64Var(&repoSize, "repoSize", 2, "maximum size of the repo in MiB") - flag.StringVar(&checkFileNameList, "checkFileNameList", "", "comma-separated list of files to check") - flag.StringVar(&checkFileSumList, "checkFileSumList", "", "comma-separated list of expected checksums") - flag.StringVar(&whitelistedChars, "whitelistedChars", "", "comma-separated list of non-ASCII characters allowed in files") - flag.StringVar(&allowedDomainList, "allowedDomainList", "sjtu.edu.cn", "comma-separated list of allowed domains for commit author email") - flag.StringVar(&actorCsvPath, "actorCsvPath", "/home/tt/.config/joj/students.csv", "path to actor csv file") - parseMultiValueFlag(&metaFile, "meta", "meta files to check") -} - -func main() { - flag.Parse() - if *showVersion { - fmt.Println(Version) - return +func run(args []string, stdout io.Writer) error { + flags := flag.NewFlagSet("repo-health-checker", flag.ContinueOnError) + flags.SetOutput(io.Discard) + var ( + rootDir string + repoSize float64 + checkFileNameList string + checkFileSumList string + metaFile []string + whitelistedChars string + allowedDomainList string + actorCsvPath string + showVersion bool + ) + flags.BoolVar(&showVersion, "version", false, "print current version") + flags.StringVar(&rootDir, "root", ".", "root dir for forbidden files check") + flags.Float64Var(&repoSize, "repoSize", 2, "maximum size of the repo in MiB") + flags.StringVar(&checkFileNameList, "checkFileNameList", "", "comma-separated list of files to check") + flags.StringVar(&checkFileSumList, "checkFileSumList", "", "comma-separated list of expected checksums") + flags.StringVar(&whitelistedChars, "whitelistedChars", "", "comma-separated list of non-ASCII characters allowed in files") + flags.StringVar(&allowedDomainList, "allowedDomainList", "sjtu.edu.cn", "comma-separated list of allowed domains for commit author email") + flags.StringVar(&actorCsvPath, "actorCsvPath", "/home/tt/.config/joj/students.csv", "path to actor csv file") + parseMultiValueFlag(flags, &metaFile, "meta", "meta files to check") + if err := flags.Parse(args); err != nil { + return err + } + if showVersion { + _, err := fmt.Fprintln(stdout, Version) + return err } setupSlog() slog.Info("start repo-health-checker", "version", Version) @@ -92,7 +94,15 @@ func main() { jsonRes, err := json.Marshal(res) if err != nil { slog.Error("marshal result", "error", err) + return err + } + _, err = fmt.Fprintln(stdout, string(jsonRes)) + return err +} + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + slog.Error("repo-health-checker", "error", err) os.Exit(1) } - fmt.Println(string(jsonRes)) } diff --git a/cmd/repo-health-checker/main_test.go b/cmd/repo-health-checker/main_test.go new file mode 100644 index 0000000..055bd43 --- /dev/null +++ b/cmd/repo-health-checker/main_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/joint-online-judge/JOJ3/internal/stage" + "github.com/joint-online-judge/JOJ3/pkg/healthcheck" +) + +type exampleConf struct { + Stages []struct { + Executor struct { + With struct { + Default struct { + Args []string `json:"args"` + } `json:"default"` + } `json:"with"` + } `json:"executor"` + } `json:"stages"` +} + +func readJSON[T any](t *testing.T, path string) T { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var value T + if err := json.Unmarshal(data, &value); err != nil { + t.Fatal(err) + } + return value +} + +func TestHealthcheckExamples(t *testing.T) { + root := "../../tmp/submodules/JOJ3-examples/examples/healthcheck" + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + // This fixture is retained in cmd/joj3 as the sandbox binary/parser smoke + // test, so do not execute it a second time here. + if entry.Name() == "release" { + continue + } + t.Run(entry.Name(), func(t *testing.T) { + dir := filepath.Join(root, entry.Name()) + conf := readJSON[exampleConf](t, filepath.Join(dir, "conf.json")) + expected := readJSON[[]stage.StageResult](t, filepath.Join(dir, "expected.json")) + if len(conf.Stages) != 1 || len(expected) != 1 || len(expected[0].Results) != 1 { + t.Fatal("healthcheck fixture must contain one stage and one result") + } + args := conf.Stages[0].Executor.With.Default.Args + if len(args) == 0 { + t.Fatal("healthcheck fixture has no command") + } + t.Chdir(dir) + var stdout bytes.Buffer + if err := run(args[1:], &stdout); err != nil { + t.Fatal(err) + } + var got healthcheck.Result + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode output %q: %v", stdout.String(), err) + } + want := healthcheck.Result{ + Msg: expected[0].Results[0].Comment, + Failed: expected[0].ForceQuit, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("run() = %+v, want %+v", got, want) + } + }) + } +} + +func TestRunVersionAndInvalidFlag(t *testing.T) { + oldVersion := Version + Version = "test-version" + t.Cleanup(func() { Version = oldVersion }) + var stdout bytes.Buffer + if err := run([]string{"-version"}, &stdout); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(stdout.String()) != Version { + t.Fatalf("version output = %q", stdout.String()) + } + if err := run([]string{"-unknown"}, &stdout); err == nil { + t.Fatal("run() accepted an unknown flag") + } +} diff --git a/internal/executor/local/executor.go b/internal/executor/local/executor.go index 7695f8e..a6759d4 100644 --- a/internal/executor/local/executor.go +++ b/internal/executor/local/executor.go @@ -71,14 +71,12 @@ func (e *Local) generateResult( if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { + result.Status = stage.StatusNonzeroExitStatus status := exitErr.Sys().(syscall.WaitStatus) if status.Signaled() { signal := status.Signal() - switch signal { - case syscall.SIGXCPU: + if signal == syscall.SIGXCPU { result.Status = stage.StatusTimeLimitExceeded - default: - result.Status = stage.StatusNonzeroExitStatus } } result.Error = exitErr.Error() diff --git a/internal/executor/local/executor_test.go b/internal/executor/local/executor_test.go index 136d318..578dc84 100644 --- a/internal/executor/local/executor_test.go +++ b/internal/executor/local/executor_test.go @@ -1,16 +1,120 @@ package local import ( + "bytes" "context" "errors" "os" + "os/exec" "path/filepath" + "strings" "testing" "time" "github.com/joint-online-judge/JOJ3/internal/stage" ) +func stringPtr(s string) *string { return &s } + +func TestRunCapturesIOAndCopyOut(t *testing.T) { + dir := t.TempDir() + output := filepath.Join(dir, "artifact") + results, err := (&Local{}).Run(context.Background(), []stage.Cmd{{ + Args: []string{"/bin/sh", "-c", "read value; printf '%s:%s' \"$MARK\" \"$value\"; printf artifact > \"$1\"", "sh", output}, + Env: []string{"MARK=env"}, + Stdin: &stage.CmdFile{Content: stringPtr("input\n")}, + Stdout: &stage.CmdFile{Name: stringPtr("stdout")}, + CopyOut: []string{output, filepath.Join(dir, "optional?")}, + }}) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Status != stage.StatusAccepted { + t.Fatalf("Run() results = %+v", results) + } + if got := results[0].Files["stdout"]; got != "env:input" { + t.Fatalf("stdout = %q", got) + } + if got := results[0].Files[output]; got != "artifact" { + t.Fatalf("copy-out = %q", got) + } +} + +func TestGenerateResultClassifiesErrors(t *testing.T) { + result := (&Local{}).generateResult(errors.New("start failed"), nil, -time.Second, + stage.Cmd{}, bytes.Buffer{}, bytes.Buffer{}, false) + if result.Status != stage.StatusInternalError || result.ExitStatus != -1 || result.RunTime != 0 { + t.Fatalf("generateResult() = %+v", result) + } + + cmd := exec.Command("/bin/sh", "-c", "exit 7") + err := cmd.Run() + result = (&Local{}).generateResult(err, cmd.ProcessState, time.Millisecond, + stage.Cmd{}, bytes.Buffer{}, bytes.Buffer{}, false) + if result.Status != stage.StatusNonzeroExitStatus || result.ExitStatus != 7 { + t.Fatalf("exit result = %+v", result) + } +} + +func TestRunReportsRequiredCopyOutError(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + results, err := (&Local{}).Run(context.Background(), []stage.Cmd{{ + Args: []string{"/bin/true"}, CopyOut: []string{missing}, + }}) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Status != stage.StatusFileError || results[0].Error == "" { + t.Fatalf("Run() results = %+v", results) + } +} + +func TestRunReadsStdinFileAndRunsMultipleCommands(t *testing.T) { + input := filepath.Join(t.TempDir(), "stdin") + if err := os.WriteFile(input, []byte("from-file"), 0o600); err != nil { + t.Fatal(err) + } + results, err := (&Local{}).Run(context.Background(), []stage.Cmd{ + {Args: []string{"/bin/true"}}, + { + Args: []string{"/bin/cat"}, + Stdin: &stage.CmdFile{Src: &input}, + Stdout: &stage.CmdFile{Name: stringPtr("stdout")}, + }, + }) + if err != nil { + t.Fatal(err) + } + if len(results) != 2 || results[1].Files["stdout"] != "from-file" { + t.Fatalf("Run() results = %+v", results) + } +} + +func TestRunSetupErrors(t *testing.T) { + _, err := (&Local{}).Run(context.Background(), []stage.Cmd{{ + Args: []string{"/bin/cat"}, Stdin: &stage.CmdFile{Src: stringPtr(filepath.Join(t.TempDir(), "missing"))}, + }}) + if err == nil || !strings.Contains(err.Error(), "failed to open stdin file") { + t.Fatalf("stdin error = %v", err) + } + _, err = (&Local{}).Run(context.Background(), []stage.Cmd{{Args: []string{filepath.Join(t.TempDir(), "missing")}}}) + if err == nil || !strings.Contains(err.Error(), "failed to start command") { + t.Fatalf("start error = %v", err) + } +} + +func TestRunClockTimeout(t *testing.T) { + results, err := (&Local{}).Run(context.Background(), []stage.Cmd{{ + Args: []string{"/bin/sleep", "1"}, ClockLimit: uint64(20 * time.Millisecond), + }}) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Status != stage.StatusTimeLimitExceeded { + t.Fatalf("Run() results = %+v", results) + } +} + func TestRunRejectsEmptyArgs(t *testing.T) { _, err := (&Local{}).Run(context.Background(), []stage.Cmd{{}}) if err == nil || err.Error() != "command args must not be empty" { diff --git a/internal/executor/sandbox/convert_test.go b/internal/executor/sandbox/convert_test.go index f3b56f6..5bc2491 100644 --- a/internal/executor/sandbox/convert_test.go +++ b/internal/executor/sandbox/convert_test.go @@ -7,9 +7,57 @@ import ( "strings" "testing" + "github.com/criyle/go-judge/pb" "github.com/joint-online-judge/JOJ3/internal/stage" ) +func TestConvertPBFileVariants(t *testing.T) { + content, fileID, name := "content", "cached", "output" + max := int64(123) + tests := []struct { + name string + file stage.CmdFile + check func(*pb.Request_File) bool + }{ + {"content", stage.CmdFile{Content: &content}, func(f *pb.Request_File) bool { return string(f.GetMemory().GetContent()) == content }}, + {"cached", stage.CmdFile{FileID: &fileID}, func(f *pb.Request_File) bool { return f.GetCached().GetFileID() == fileID }}, + {"pipe", stage.CmdFile{Name: &name, Max: &max, Pipe: true}, func(f *pb.Request_File) bool { + return f.GetPipe().GetName() == name && f.GetPipe().GetMax() == max && f.GetPipe().GetPipe() + }}, + {"stream-in", stage.CmdFile{StreamIn: true}, func(f *pb.Request_File) bool { return f.GetStreamIn() != nil }}, + {"stream-out", stage.CmdFile{StreamOut: true}, func(f *pb.Request_File) bool { return f.GetStreamOut() != nil }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := convertPBFile(tt.file) + if err != nil || got == nil || !tt.check(got) { + t.Fatalf("convertPBFile() = %v, %v", got, err) + } + }) + } +} + +func TestConvertCopyOutAndResult(t *testing.T) { + copyOut := convertPBCopyOut([]string{"required", "optional?"}) + if len(copyOut) != 2 || copyOut[0].GetOptional() || !copyOut[1].GetOptional() || copyOut[1].GetName() != "optional" { + t.Fatalf("convertPBCopyOut() = %+v", copyOut) + } + fileError := &pb.Response_FileError{} + fileError.SetName("input") + fileError.SetType(pb.Response_FileError_ErrorType(1)) + fileError.SetMessage("bad file") + response := &pb.Response_Result{} + response.SetStatus(pb.Response_Result_StatusType(stage.StatusAccepted)) + response.SetFiles(map[string][]byte{"stdout": []byte("ok")}) + response.SetFileIDs(map[string]string{"bin": "id"}) + response.SetFileError([]*pb.Response_FileError{fileError}) + got := convertPBResult([]*pb.Response_Result{response}) + if len(got) != 1 || got[0].Files["stdout"] != "ok" || got[0].FileIDs["bin"] != "id" || + len(got[0].FileError) != 1 || got[0].FileError[0].Message != "bad file" { + t.Fatalf("convertPBResult() = %+v", got) + } +} + func TestConvertPBCmdReturnsSourceReadError(t *testing.T) { missing := t.TempDir() + "/missing" _, err := convertPBCmd([]stage.Cmd{{ @@ -46,8 +94,10 @@ func TestConvertPBCmdReturnsCopyInDirectoryWalkError(t *testing.T) { } } -func TestConvertPBCmdPreservesMoreThanSCMMaxFDFileCount(t *testing.T) { - const fileCount = 301 +func TestConvertPBCmdPreservesFilesAcrossMultipleSCMMaxFDBatches(t *testing.T) { + // 1001 is large enough to require several descriptor batches while keeping + // the fixture cheap to create and the protobuf request small. + const fileCount = 1001 dir := t.TempDir() for i := range fileCount { path := filepath.Join(dir, fmt.Sprintf("file-%03d", i)) diff --git a/internal/executor/sandbox/grpc_test.go b/internal/executor/sandbox/grpc_test.go new file mode 100644 index 0000000..9562604 --- /dev/null +++ b/internal/executor/sandbox/grpc_test.go @@ -0,0 +1,27 @@ +package sandbox + +import ( + "context" + "testing" +) + +func TestTokenAuth(t *testing.T) { + auth := newTokenAuth("secret") + metadata, err := auth.GetRequestMetadata(context.Background()) + if err != nil || metadata["authorization"] != "Bearer secret" { + t.Fatalf("GetRequestMetadata() = %v, %v", metadata, err) + } + if auth.RequireTransportSecurity() { + t.Fatal("RequireTransportSecurity() = true") + } +} + +func TestCreateGRPCConnection(t *testing.T) { + conn, err := createGRPCConnection("passthrough:///unused", "secret") + if err != nil { + t.Fatal(err) + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/parser/keyword/parser_test.go b/internal/parser/keyword/parser_test.go new file mode 100644 index 0000000..089d5df --- /dev/null +++ b/internal/parser/keyword/parser_test.go @@ -0,0 +1,38 @@ +package keyword + +import ( + "strings" + "testing" + + "github.com/joint-online-judge/JOJ3/internal/stage" +) + +func TestRunCountsCapsAndOrdersKeywords(t *testing.T) { + results, forceQuit, err := (&Keyword{}).Run([]stage.ExecutorResult{{ + Files: map[string]string{"log": "error error warning"}, + }}, map[string]any{ + "score": 10, + "files": []any{"log"}, + "forceQuitOnDeduct": true, + "matches": []any{ + map[string]any{"keywords": []any{"error"}, "score": 3, "maxMatchCount": 1}, + map[string]any{"keywords": []any{"warning"}, "score": 2}, + }, + }) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Score != 5 || !forceQuit { + t.Fatalf("Run() = %+v, %v", results, forceQuit) + } + if !strings.Contains(results[0].Comment, "`error`: 1") || !strings.Contains(results[0].Comment, "`warning`: 1") { + t.Fatalf("comment = %q", results[0].Comment) + } +} + +func TestRunRejectsInvalidConfiguration(t *testing.T) { + _, forceQuit, err := (&Keyword{}).Run(nil, "invalid") + if err == nil || !forceQuit { + t.Fatalf("Run() = forceQuit %v, error %v", forceQuit, err) + } +} diff --git a/internal/stage/fileerror.go b/internal/stage/fileerror.go index 5bfed66..379a37d 100644 --- a/internal/stage/fileerror.go +++ b/internal/stage/fileerror.go @@ -60,7 +60,7 @@ func (t FileErrorType) MarshalJSON() ([]byte, error) { func (t *FileErrorType) UnmarshalJSON(b []byte) error { str := string(b) v, ok := fileErrorStringReverse[str] - if ok { + if !ok { return fmt.Errorf("%s is not file error type", str) } *t = v diff --git a/internal/stage/json_test.go b/internal/stage/json_test.go new file mode 100644 index 0000000..f33c888 --- /dev/null +++ b/internal/stage/json_test.go @@ -0,0 +1,67 @@ +package stage + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestStatusJSONRoundTrip(t *testing.T) { + for status := StatusInvalid; status <= StatusInternalError; status++ { + data, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + var got Status + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal %s: %v", data, err) + } + if got != status { + t.Fatalf("round trip = %v, want %v", got, status) + } + } + var status Status + if err := json.Unmarshal([]byte(`"unknown"`), &status); err == nil { + t.Fatal("unknown status was accepted") + } +} + +func TestFileErrorTypeJSONRoundTrip(t *testing.T) { + for fileError := ErrCopyInOpenFile; fileError <= ErrCollectSizeExceeded; fileError++ { + data, err := json.Marshal(fileError) + if err != nil { + t.Fatal(err) + } + var got FileErrorType + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal %s: %v", data, err) + } + if got != fileError { + t.Fatalf("round trip = %v, want %v", got, fileError) + } + } + var fileError FileErrorType + if err := json.Unmarshal([]byte(`"unknown"`), &fileError); err == nil { + t.Fatal("unknown file error type was accepted") + } +} + +func TestExecutorResultJSONSummarizesFileContents(t *testing.T) { + data, err := json.Marshal(ExecutorResult{ + Status: StatusAccepted, + Files: map[string]string{"stdout": "secret output"}, + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "secret output") || !strings.Contains(string(data), `"stdout":"len:13"`) { + t.Fatalf("MarshalJSON() = %s", data) + } +} + +func TestNonNullSliceMarshalsEmptyAsArray(t *testing.T) { + data, err := json.Marshal(NonNullSlice[int](nil)) + if err != nil || string(data) != "[]" { + t.Fatalf("MarshalJSON() = %s, %v", data, err) + } +} diff --git a/pkg/healthcheck/author_test.go b/pkg/healthcheck/author_test.go new file mode 100644 index 0000000..5a80905 --- /dev/null +++ b/pkg/healthcheck/author_test.go @@ -0,0 +1,80 @@ +package healthcheck + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +func newCommitRepo(t *testing.T, message, email string) string { + t.Helper() + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("test"), 0o600); err != nil { + t.Fatal(err) + } + worktree, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + if _, err := worktree.Add("README.md"); err != nil { + t.Fatal(err) + } + signature := &object.Signature{Name: "Student", Email: email, When: time.Unix(1, 0)} + if _, err := worktree.Commit(message, &git.CommitOptions{Author: signature, Committer: signature}); err != nil { + t.Fatal(err) + } + return dir +} + +func TestCommitChecks(t *testing.T) { + valid := newCommitRepo(t, "feat: valid", "student@example.edu") + if err := NonASCIIMsg(valid); err != nil { + t.Fatal(err) + } + if err := AuthorEmailCheck(valid, []string{"example.edu"}, filepath.Join(valid, "missing.csv")); err != nil { + t.Fatal(err) + } + + invalidMessage := newCommitRepo(t, "feat: 测试", "student@invalid.test") + if err := NonASCIIMsg(invalidMessage); err == nil || !strings.Contains(err.Error(), "测试") { + t.Fatalf("NonASCIIMsg() error = %v", err) + } + if err := AuthorEmailCheck(invalidMessage, []string{"example.edu"}, filepath.Join(invalidMessage, "missing.csv")); err == nil || !strings.Contains(err.Error(), "allowed domains") { + t.Fatalf("AuthorEmailCheck(domain) error = %v", err) + } +} + +func TestAuthorEmailCheckActorCSV(t *testing.T) { + repo := newCommitRepo(t, "feat: valid", "student@example.edu") + csvPath := filepath.Join(t.TempDir(), "actors.csv") + if err := os.WriteFile(csvPath, []byte("name,id,student\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := AuthorEmailCheck(repo, []string{"example.edu"}, csvPath); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(csvPath, []byte("name,id,other\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := AuthorEmailCheck(repo, []string{"example.edu"}, csvPath); err == nil || !strings.Contains(err.Error(), "not stored") { + t.Fatalf("AuthorEmailCheck(actor) error = %v", err) + } +} + +func TestCommitChecksRejectNonRepository(t *testing.T) { + if err := NonASCIIMsg(t.TempDir()); err == nil || !strings.Contains(err.Error(), "opening git repo") { + t.Fatalf("NonASCIIMsg() error = %v", err) + } + if err := AuthorEmailCheck(t.TempDir(), nil, ""); err == nil || !strings.Contains(err.Error(), "opening git repo") { + t.Fatalf("AuthorEmailCheck() error = %v", err) + } +} diff --git a/pkg/healthcheck/files_test.go b/pkg/healthcheck/files_test.go new file mode 100644 index 0000000..ce429e5 --- /dev/null +++ b/pkg/healthcheck/files_test.go @@ -0,0 +1,67 @@ +package healthcheck + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseWhitelistedChars(t *testing.T) { + got := parseWhitelistedChars("你, 好, a, invalid, ,你") + if len(got) != 2 { + t.Fatalf("parseWhitelistedChars() = %v", got) + } + if _, ok := got['你']; !ok { + t.Fatal("missing whitelisted rune") + } +} + +func TestNonASCIIFiles(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("hello 你\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := NonASCIIFiles(dir, "你"); err != nil { + t.Fatalf("whitelisted NonASCIIFiles() error = %v", err) + } + if err := NonASCIIFiles(dir, ""); err == nil || !strings.Contains(err.Error(), "source.txt") { + t.Fatalf("NonASCIIFiles() error = %v", err) + } +} + +func TestForbiddenCheck(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.out\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "result.out"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := ForbiddenCheck("."); err == nil || !strings.Contains(err.Error(), "result.out") { + t.Fatalf("ForbiddenCheck() error = %v", err) + } +} + +func TestVerifyFiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "protected.txt") + content := []byte("original") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(content) + checksum := hex.EncodeToString(sum[:]) + if err := VerifyFiles(dir, "protected.txt", checksum); err != nil { + t.Fatal(err) + } + if err := VerifyFiles(dir, "protected.txt", "bad"); err == nil || !strings.Contains(err.Error(), "altered") { + t.Fatalf("VerifyFiles(altered) error = %v", err) + } + if err := VerifyFiles(dir, "one,two", checksum); err == nil || !strings.Contains(err.Error(), "do not match") { + t.Fatalf("VerifyFiles(mismatch) error = %v", err) + } +}