diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index 19ff8d9..4cb8f5c 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -23,15 +23,15 @@ done # Get logs and get rid of containers logs=$(docker logs jury-testing) docker compose -f docker-compose.test.yml down +printf "$logs" # If no failed lines, exit with success failed=$(echo "$logs" | grep "failed") if [[ -z "$failed" ]]; then - echo "Success :)" + printf "\n\nSuccess :)\n" exit 0 fi # Otherwise, exit with failure -printf "$logs" printf "\n\n###################################\n##### THERE ARE TEST FAILURES #####\n###################################\n" exit 1 diff --git a/server/router/admin.go b/server/router/admin.go index f3bf4ad..6b15f67 100644 --- a/server/router/admin.go +++ b/server/router/admin.go @@ -139,7 +139,7 @@ func PauseClock(ctx *gin.Context) { // Send OK state.Logger.AdminLogf("Paused clock") - ctx.JSON(http.StatusOK, gin.H{"clock": state.Clock.State}) + ctx.JSON(http.StatusOK, gin.H{"clock": state.Clock.State, "ok": 1}) } // POST /admin/clock/unpause - UnpauseClock unpauses the clock @@ -163,7 +163,7 @@ func UnpauseClock(ctx *gin.Context) { // Send OK state.Logger.AdminLogf("Unpaused clock") - ctx.JSON(http.StatusOK, gin.H{"clock": state.Clock.State}) + ctx.JSON(http.StatusOK, gin.H{"clock": state.Clock.State, "ok": 1}) } // POST /admin/clock/reset - ResetClock resets the clock diff --git a/server/router/judge.go b/server/router/judge.go index 6572a39..6fbbb1c 100644 --- a/server/router/judge.go +++ b/server/router/judge.go @@ -1019,13 +1019,14 @@ func MoveSelectedJudges(ctx *gin.Context) { } type AddJudgeFromQRRequest struct { - Code string `json:"code"` - Name string `json:"name"` - Email string `json:"email"` - Track string `json:"track"` + Code string `json:"code"` + Name string `json:"name"` + Email string `json:"email"` + Track string `json:"track"` + NoSend *bool `json:"no_send"` } -// POST /judge/qr - Add a judge from a QR code +// POST /judge/qr/add - Add a judge from a QR code func AddJudgeFromQR(ctx *gin.Context) { // Get the state from the context state := GetState(ctx) @@ -1095,10 +1096,12 @@ func AddJudgeFromQR(ctx *gin.Context) { } // Send email to judge - err = funcs.SendJudgeEmail(judge, hostname) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error sending judge email: " + err.Error()}) - return err + if qrReq.NoSend == nil || !*qrReq.NoSend { + err = funcs.SendJudgeEmail(judge, hostname) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "error sending judge email: " + err.Error()}) + return err + } } // Insert the judge into the database @@ -1114,6 +1117,12 @@ func AddJudgeFromQR(ctx *gin.Context) { return } + // Guard against early transaction exit leaving judge nil + if judge == nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "unexpected error creating judge"}) + return + } + // Send OK track := "" if qrReq.Track != "" { diff --git a/server/router/project.go b/server/router/project.go index 2bcdacf..dabb397 100644 --- a/server/router/project.go +++ b/server/router/project.go @@ -2,7 +2,6 @@ package router import ( "errors" - "fmt" "net/http" "server/database" "server/funcs" @@ -352,8 +351,6 @@ func DeleteProject(ctx *gin.Context) { // Get the id from the request id := ctx.Param("id") - fmt.Println("hello1") - // Convert judge ID string to ObjectID projectObjectId, err := primitive.ObjectIDFromHex(id) if err != nil { @@ -370,8 +367,6 @@ func DeleteProject(ctx *gin.Context) { return err } - fmt.Println("hello2") - // Delete all instances of this project from judges' seen projects array and rankings err = database.DeleteSeenProject(state.Db, sc, &projectObjectId) if err != nil { @@ -379,8 +374,6 @@ func DeleteProject(ctx *gin.Context) { return err } - fmt.Println("hello3") - // Delete all flags for this project err = database.DeleteFlagsCascade(state.Db, sc, &projectObjectId, nil) if err != nil { @@ -388,16 +381,12 @@ func DeleteProject(ctx *gin.Context) { return err } - fmt.Println("hello4") - return nil }) if err != nil { return } - fmt.Println("hello3.5") - // Update all judge rankings err = judging.InitAggregateRankings(state.Db) if err != nil { @@ -405,8 +394,6 @@ func DeleteProject(ctx *gin.Context) { return } - fmt.Println("hello5") - // Send OK state.Logger.AdminLogf("Deleted project %s", id) ctx.JSON(http.StatusOK, gin.H{"ok": 1}) @@ -803,6 +790,10 @@ func MoveProject(ctx *gin.Context) { if err != nil { return } + + // Send OK + state.Logger.AdminLogf("Moved project %s to table %d", id.Hex(), moveReq.Location) + ctx.JSON(http.StatusOK, gin.H{"ok": 1}) } // POST /project/move - Move selected projects to a different group diff --git a/tests/go.mod b/tests/go.mod index f88c316..90f8a31 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -2,13 +2,15 @@ module tests go 1.23.1 -require go.mongodb.org/mongo-driver v1.17.1 +require ( + github.com/valyala/fastjson v1.6.4 + go.mongodb.org/mongo-driver v1.17.1 +) require ( github.com/golang/snappy v0.0.4 // indirect github.com/klauspost/compress v1.13.6 // indirect github.com/montanaflynn/stats v0.7.1 // indirect - github.com/valyala/fastjson v1.6.4 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/tests/main.go b/tests/main.go index 0e57a68..bcd9093 100644 --- a/tests/main.go +++ b/tests/main.go @@ -3,32 +3,32 @@ package main import ( "context" "os" - "tests/src" "tests/tests" + "tests/util" ) func main() { // Initialize the logger (make sure to change the .gitignore if this filename ever changes) - logger := src.NewLogger("test-log.txt") + logger := util.NewLogger("test-log.txt") // Log start message with date and time - logger.LogLn(src.Info, "\n===============\nTESTING STARTED\n===============") - logger.Log(src.Info, "Date/time: %s\n", src.GetDateTime()) + logger.LogLn(util.Info, "\n===============\nTESTING STARTED\n===============") + logger.Log(util.Info, "Date/time: %s\n", util.GetDateTime()) // Initialize the database connection - db := src.InitDb(logger) + db := util.InitDb(logger) // Close the database connection defer db.Client().Disconnect(context.Background()) // Wait for backend to load - err := src.WaitForBackend(logger) + err := util.WaitForBackend(logger) if err != nil { os.Exit(1) } // Create a context with the database and logger - context := &src.Context{ + context := &util.Context{ Db: db, Logger: logger, } diff --git a/tests/src/requests.go b/tests/src/requests.go deleted file mode 100644 index 05a791a..0000000 --- a/tests/src/requests.go +++ /dev/null @@ -1,185 +0,0 @@ -package src - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "io" - "net/http" - "strings" - "time" - - "github.com/valyala/fastjson" -) - -type H map[string]any - -// WaitForBackend will wait for the backend to load, checking the URL every 5 seconds -func WaitForBackend(logger *Logger) error { - logger.Log(Info, "Waiting for backend to load...\n") - - url := getBaseUrl() - retry := 3 // Retry 3 times before giving up - - for { - // Send a GET request to the backend - res, err := http.Get(url) - if err != nil { - if (retry < 0) { - logger.Log(Error, "Error: failed to connect to backend. Aborting.") - return errors.New("failed to connect to backend") - } - - logger.Log(Info, "Error sending GET request to %s, waiting 5 seconds: %s\n", url, err.Error()) - time.Sleep(5 * time.Second) - retry-- - continue - } - - if res.StatusCode == http.StatusOK { - logger.LogLn(Info, "Backend loaded") - break - } - } - return nil -} - -func GetRequest(logger *Logger, url string, authHeader string) string { - logger.Log(Verbose, "Sending GET request to %s\n", url) - - fullUrl := getBaseUrl() + url - - // Send the GET request - req, err := http.NewRequest("GET", fullUrl, nil) - if err != nil { - logger.Log(Error, "Error creating GET request to %s: %s\n", url, err.Error()) - return err.Error() - } - if authHeader != "" { - req.Header.Set("Authorization", authHeader) - } - res, err := http.DefaultClient.Do(req) - if err != nil { - logger.Log(Error, "Error sending GET request to %s: %s\n", url, err.Error()) - return err.Error() - } - - // Read the body - defer res.Body.Close() - resBody, err := io.ReadAll(res.Body) - if err != nil { - logger.Log(Error, "Error reading response body: %s\n", err.Error()) - return err.Error() - } - - logger.Log(Verbose, "Response: %s\n", string(resBody)) - - return string(resBody) -} - -func PostRequest(logger *Logger, url string, body H, authHeader string) string { - logger.Log(Verbose, "Sending POST request to %s\n", url) - - jsonBody, err := json.Marshal(body) - if err != nil { - logger.Log(Error, "Error marshalling body of POST request to %s: %s\n", url, err.Error()) - return err.Error() - } - fullUrl := getBaseUrl() + url - - logger.Log(Verbose, "Request Body: %s\n", jsonBody) - - // Send the POST request - req, err := http.NewRequest("POST", fullUrl, bytes.NewBuffer(jsonBody)) - if err != nil { - logger.Log(Error, "Error creating POST request to %s: %s\n", url, err.Error()) - return err.Error() - } - if authHeader != "" { - req.Header.Set("Authorization", authHeader) - } - res, err := http.DefaultClient.Do(req) - if err != nil { - logger.Log(Error, "Error sending POST request to %s: %s\n", url, err.Error()) - return err.Error() - } - - // Read the body - defer res.Body.Close() - resBody, err := io.ReadAll(res.Body) - if err != nil { - logger.Log(Error, "Error reading response body: %s\n", err.Error()) - return err.Error() - } - - logger.Log(Verbose, "Response: %s\n", string(resBody)) - - return string(resBody) -} - -func getBaseUrl() string { - return GetEnv("API_URL") -} - -func DefaultAuth() string { - return "" -} - -func JudgeAuth(token string) string { - return "Bearer " + token -} - -func AdminAuth() string { - return "Basic " + base64.StdEncoding.EncodeToString([]byte("admin:"+GetEnv("ADMIN_PASSWORD"))) -} - -// IsOk checks if body is okay -func IsOk(body string) bool { - return strings.Contains(body, "\"ok\":1") -} - -// AssertOk checks if body is okay and returns a Result -func AssertOk(body string, err string) Result { - if IsOk(body) { - return ResultOk() - } - - return NewResult(false, err) -} - -// AssertNotOk checks if body is not okay and returns a Result -func AssertNotOk(body string, err string) Result { - if !IsOk(body) { - return ResultOk() - } - - return NewResult(false, err) -} - -type JsonType int - -const ( - StringType JsonType = iota - IntType - BoolType - Float64Type -) - -// IsValue checks if body contains key with value -func IsValue(body string, key string, valueType JsonType, value any) bool { - data := []byte(body) - - switch valueType { - case StringType: - return fastjson.GetString(data, key) == value - case IntType: - return fastjson.GetInt(data, key) == value - case BoolType: - return fastjson.GetBool(data, key) == value - case Float64Type: - return fastjson.GetFloat64(data, key) == value - default: - return false - } -} diff --git a/tests/tests/all.go b/tests/tests/all.go index 0f3f1c0..560bb45 100644 --- a/tests/tests/all.go +++ b/tests/tests/all.go @@ -1,56 +1,159 @@ package tests -import "tests/src" +import ( + "encoding/json" + "tests/util" + "time" +) + +// jsonUnmarshalList is a shared helper used across test files to parse JSON arrays +func jsonUnmarshalList(body string, v any) error { + return json.Unmarshal([]byte(body), v) +} type TestGroup struct { Name string - Tests map[string]func(*src.Context) src.Result + Setup func(*util.Context) util.Result + Tests map[string]func(*util.Context) util.Result } -func RunTests(context *src.Context) { - // ADD TESTS AND GROUPS HERE +func RunTests(context *util.Context) { tests := []TestGroup{ { - Name: "Endpoint Unit Tests", - Tests: map[string]func(*src.Context) src.Result{ - "Heartbeat": Heartbeat, - "Invalid Admin Log In": InvalidAdminLogin, - "Valid Admin Log In": AdminLogin, - "Invalid Admin Auth": InvalidAdminAuth, - "Valid Admin Auth": AdminAuth, - "Add Judge": AddJudge, - "Get Clock": GetClock, + Name: "Smoke Tests", + Tests: map[string]func(*util.Context) util.Result{ + "Heartbeat": Heartbeat, + "Admin Login": SmokeAdminLogin, + "Admin Auth": SmokeAdminAuth, + }, + }, + { + Name: "Auth & Access Control", + Tests: map[string]func(*util.Context) util.Result{ + "Unauthenticated Requests Rejected": UnauthenticatedRequestsRejected, + "Invalid Judge Token Rejected": InvalidJudgeAuth, + "Judge Token Cannot Access Admin Routes": JudgeTokenCannotAccessAdminRoutes, + "Admin Credentials Cannot Act As Judge": AdminAuthWithJudgeEndpoint, + "Empty Bearer Token Rejected": EmptyBearerTokenRejected, + "Empty Bearer Token Cannot Access Judge Routes": EmptyBearerTokenCannotAccessJudgeRoutes, + "Bearer Prefix Only Rejected": BearerPrefixOnlyRejected, + "Uninitialized Judge Cannot Be Impersonated": UninitializedJudgeCannotBeImpersonated, + }, + }, + { + Name: "Judge CRUD", + Tests: map[string]func(*util.Context) util.Result{ + "Judge Login With Valid Code": JudgeLoginWithValidCode, + "Judge Login With Invalid Code": JudgeLoginWithInvalidCode, + "Add And Delete Judge": AddAndDeleteJudge, + "Edit Judge": EditJudge, + "Hide Judge": HideJudge, + "Judge Welcome Flow": JudgeWelcomeFlow, + "Judge Stats Reflect Additions": JudgeStatsReflectAdditions, + "QR Check Empty Code Rejected": QRCheckEmptyCodeRejected, + "QR Check Track Empty Code Rejected": QRCheckTrackEmptyCodeRejected, + "QR Add Empty Code Does Not Create Judge": QRAddEmptyCodeDoesNotCreateJudge, + "QR Add Garbage Code Does Not Create Judge": QRAddGarbageCodeDoesNotCreateJudge, + "QR Valid Flow Still Works": QRValidFlowStillWorks, + }, + }, + { + Name: "Project CRUD", + Tests: map[string]func(*util.Context) util.Result{ + "Add Project": AddProject, + "Add And Delete Project": AddAndDeleteProject, + "Delete Non-Existent Project": DeleteNonExistentProject, + "Edit Project": EditProject, + "Hide Project": HideProject, + "Prioritize Project": PrioritizeProject, + "Move Project To Table": MoveProject, + "Public Project List Accessible": PublicProjectListIsAccessible, + "Project Stats Reflect Additions": ProjectStatsReflectAdditions, + }, + }, + { + Name: "Judging Workflow", + Setup: JudgingTestSetup, + Tests: map[string]func(*util.Context) util.Result{ + "Judging Standard Path": JudgingStandardPath, + "Judge Does Not Repeat Projects": JudgeDoesNotRepeatProjects, + "Skip Project Creates Flag": SkipProjectCreatesFlag, + "Judge Rank Projects": JudgeRankProjects, + "Star Project": StarProject, + "Judge Notes Update": JudgeNotesUpdate, + "Judge Next With No Projects": JudgeNextWithNoActiveProjects, + }, + }, + { + Name: "Clock & Admin Settings", + Tests: map[string]func(*util.Context) util.Result{ + "Clock Pause And Unpause": ClockPauseUnpause, + "Clock Reset": ClockReset, + "Admin Started Reflects Clock": AdminStartedReflectsClockState, + "Get Options Returns Valid Structure": GetOptionsReturnsValidStructure, + "Set And Get Judging Timer": SetAndGetJudgingTimer, + "Set Min Views": SetMinViews, + "Deliberation Toggle": DeliberationToggle, + "Block Reqs Toggle": BlockReqsToggle, }, }, } - // Loop through each test group and run each test in the group + totalErrors := 0 + for _, group := range tests { - context.Logger.Log(src.Info, "\n%s\n------------------\n", group.Name) - context.Logger.Log(src.Info, "Date/time: %s\n", src.GetDateTime()) - context.Logger.Log(src.Info, "Found %d tests\n", len(group.Tests)) - context.Logger.Log(src.Info, "Running tests...\n") - context.Logger.Log(src.Info, "------------------\n") + context.Logger.Log(util.Info, "\n%s\n------------------\n", group.Name) + context.Logger.Log(util.Info, "Date/time: %s\n", util.GetDateTime()) + context.Logger.Log(util.Info, "Found %d tests\n", len(group.Tests)) + context.Logger.Log(util.Info, "Running tests...\n") + context.Logger.Log(util.Info, "------------------\n") errors := 0 + // Run the setup script if it exists + // If it fails, assume we cannot continue this test set + if group.Setup != nil { + context.Logger.LogLn(util.Info, "Running setup") + res := group.Setup(context) + if !res.Success { + context.Logger.Log(util.Error, "\tFAILED: %s\n", res.Message) + context.Logger.LogLn(util.Info, "------------------") + context.Logger.LogLn(util.Error, "Could not run tests due to failure of setup script") + context.Logger.LogLn(util.Info, "------------------\n") + continue + } + context.Logger.LogLn(util.Info, "\tSuccess") + time.Sleep(100 * time.Millisecond) + } + for name, test := range group.Tests { - context.Logger.Log(src.Info, "Running test: %s\n", name) + context.Logger.Log(util.Info, "Running test: %s\n", name) res := test(context) if !res.Success { - context.Logger.Log(src.Error, "\tFAILED: %s\n", res.Message) + context.Logger.Log(util.Error, "\tFAILED: %s\n", res.Message) errors++ } else { - context.Logger.LogLn(src.Info, "\tPassed") + context.Logger.LogLn(util.Info, "\tPassed") } + + // Add delay between tests + time.Sleep(100 * time.Millisecond) } - context.Logger.LogLn(src.Info, "------------------") + context.Logger.LogLn(util.Info, "------------------") if errors == 0 { - context.Logger.LogLn(src.Info, "All tests passed!") + context.Logger.LogLn(util.Info, "All tests passed!") } else { - context.Logger.Log(src.Error, "%d tests failed\n", errors) + context.Logger.Log(util.Error, "%d tests failed\n", errors) } - context.Logger.LogLn(src.Info, "------------------\n") + context.Logger.LogLn(util.Info, "------------------\n") + + totalErrors += errors } -} + + if totalErrors == 0 { + context.Logger.LogLn(util.Info, "Entire test suite passed!") + } else { + context.Logger.Log(util.Error, "There are %d failing tests across the test suite\n", totalErrors) + } +} \ No newline at end of file diff --git a/tests/tests/auth.go b/tests/tests/auth.go new file mode 100644 index 0000000..dd72790 --- /dev/null +++ b/tests/tests/auth.go @@ -0,0 +1,234 @@ +package tests + +import "tests/util" + +// --- Auth / Access Control Tests --- + +// JudgeTokenCannotAccessAdminRoutes verifies that a valid judge token is rejected on admin-only endpoints +func JudgeTokenCannotAccessAdminRoutes(context *util.Context) util.Result { + // Add a judge and get their token + token, result := createJudgeAndGetToken(context) + if !result.Success { + return result + } + + judgeAuth := util.JudgeAuth(token) + + // Try hitting several admin-only endpoints with the judge token + adminRoutes := []struct { + method string + url string + }{ + {"GET", "/judge/list"}, + {"GET", "/admin/stats"}, + {"GET", "/admin/flags"}, + {"GET", "/admin/options"}, + } + + for _, route := range adminRoutes { + var status int + if route.method == "GET" { + status, _ = util.GetRequestWithStatus(context.Logger, route.url, judgeAuth) + } else { + status, _ = util.PostRequestWithStatus(context.Logger, route.url, nil, judgeAuth) + } + if status != 401 && status != 403 { + return util.NewResult(false, "Judge token should not be able to access admin route "+route.url+" (got status "+statusStr(status)+")") + } + } + + return util.ResultOk() +} + +// UnauthenticatedRequestsRejected verifies that admin routes reject requests with no auth at all +func UnauthenticatedRequestsRejected(context *util.Context) util.Result { + adminRoutes := []string{ + "/judge/list", + "/admin/stats", + "/admin/flags", + "/admin/options", + "/admin/clock", + } + + for _, url := range adminRoutes { + status, _ := util.GetRequestWithStatus(context.Logger, url, util.DefaultAuth()) + if status != 401 && status != 403 { + return util.NewResult(false, "Unauthenticated request should be rejected on "+url+" (got status "+statusStr(status)+")") + } + } + + return util.ResultOk() +} + +// InvalidJudgeAuth verifies that a garbage judge token is rejected +func InvalidJudgeAuth(context *util.Context) util.Result { + status, _ := util.GetRequestWithStatus(context.Logger, "/judge", util.JudgeAuth("totally-invalid-token-xyz")) + if status == 200 { + return util.NewResult(false, "Invalid judge token should not authenticate successfully") + } + return util.ResultOk() +} + +// AdminAuthWithJudgeEndpoint verifies admin credentials work on /admin/auth but not as a judge token +func AdminAuthWithJudgeEndpoint(context *util.Context) util.Result { + // Admin auth should work on /admin/auth + res := util.PostRequest(context.Logger, "/admin/auth", nil, util.AdminAuth()) + if !util.IsOk(res) { + return util.NewResult(false, "Admin auth should succeed on /admin/auth") + } + + // Admin credentials should NOT work as a judge token on judge routes + status, _ := util.GetRequestWithStatus(context.Logger, "/judge", util.AdminAuth()) + if status == 200 { + return util.NewResult(false, "Admin credentials should not authenticate as a judge") + } + + return util.ResultOk() +} + +// helper: creates a throw-away judge via the admin API and returns their login token +func createJudgeAndGetToken(context *util.Context) (string, util.Result) { + addRes := util.PostRequest(context.Logger, "/judge/new", util.H{ + "name": "Auth Test Judge", + "email": "authtest@example.com", + "track": "", + "notes": "", + "no_send": true, + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return "", util.NewResult(false, "Failed to create judge for auth test: "+addRes) + } + + // Get the judge list to find the login code + listRes := util.GetRequest(context.Logger, "/judge/list", util.AdminAuth()) + code := extractJudgeCode(listRes, "authtest@example.com") + if code == "" { + return "", util.NewResult(false, "Could not find judge code in judge list") + } + + loginRes := util.PostRequest(context.Logger, "/judge/login", util.H{"code": code}, util.DefaultAuth()) + token := util.ExtractString(loginRes, "token") + if token == "" { + return "", util.NewResult(false, "Judge login did not return a token") + } + + return token, util.ResultOk() +} + +// helper: converts a status int to a string for error messages +func statusStr(status int) string { + switch status { + case 200: + return "200 OK" + case 401: + return "401 Unauthorized" + case 403: + return "403 Forbidden" + case 404: + return "404 Not Found" + default: + return "unknown" + } +} + +// EmptyBearerTokenRejected verifies that "Authorization: Bearer " (empty token +// after the prefix) is rejected outright, even when uninitialized judges exist +// in the database with token "". +func EmptyBearerTokenRejected(context *util.Context) util.Result { + // Ensure at least one judge exists with an uninitialized (empty) token + // by adding a judge but NOT logging them in. + util.PostRequest(context.Logger, "/judge/new", util.H{ + "name": "Uninitialized Token Judge", + "email": "uninit_token@example.com", + "track": "", + "notes": "", + "no_send": true, + }, util.AdminAuth()) + + // "Bearer " — exactly 7 characters, empty string after slicing prefix + status, _ := util.GetRequestWithStatus(context.Logger, "/judge", "Bearer ") + if status == 200 { + return util.NewResult(false, "\"Bearer \" (empty token) must not authenticate — matched uninitialized judge") + } + return util.ResultOk() +} + +// EmptyBearerTokenCannotAccessJudgeRoutes checks the bypass across multiple +// judge-authenticated endpoints, not just GET /judge. +// Basically a check for passing an empty token into the Bearer auth. +func EmptyBearerTokenCannotAccessJudgeRoutes(context *util.Context) util.Result { + // Add an uninitialized judge to guarantee token "" exists in Mongo + util.PostRequest(context.Logger, "/judge/new", util.H{ + "name": "Uninitialized Token Judge 2", + "email": "uninit_token2@example.com", + "track": "", + "notes": "", + "no_send": true, + }, util.AdminAuth()) + + emptyBearerAuth := "Bearer " + + judgeRoutes := []struct { + method string + url string + }{ + {"GET", "/judge"}, + {"GET", "/judge/welcome"}, + {"GET", "/judge/projects"}, + {"POST", "/judge/next"}, + {"GET", "/judge/deliberation"}, + } + + for _, route := range judgeRoutes { + var status int + if route.method == "GET" { + status, _ = util.GetRequestWithStatus(context.Logger, route.url, emptyBearerAuth) + } else { + status, _ = util.PostRequestWithStatus(context.Logger, route.url, nil, emptyBearerAuth) + } + if status == 200 { + return util.NewResult(false, "Empty Bearer token must not grant access to "+route.url+"") + } + } + + return util.ResultOk() +} + +// BearerPrefixOnlyRejected checks that "Bearer" with no space or token is also +// rejected — guards against off-by-one variants of the same class of bug. +func BearerPrefixOnlyRejected(context *util.Context) util.Result { + status, _ := util.GetRequestWithStatus(context.Logger, "/judge", "Bearer") + if status == 200 { + return util.NewResult(false, "\"Bearer\" with no token must not authenticate") + } + return util.ResultOk() +} + +// UninitializedJudgeCannotBeImpersonated verifies that a judge added but never +// logged in cannot be accessed by any near-empty token variant. +func UninitializedJudgeCannotBeImpersonated(context *util.Context) util.Result { + util.PostRequest(context.Logger, "/judge/new", util.H{ + "name": "Never Logged In Judge", + "email": "never_login@example.com", + "track": "", + "notes": "", + "no_send": true, + }, util.AdminAuth()) + + suspiciousTokens := []string{ + "Bearer ", + "Bearer ", // two spaces + "Bearer\t", // tab + "Bearer null", + "Bearer undefined", + } + + for _, authHeader := range suspiciousTokens { + status, _ := util.GetRequestWithStatus(context.Logger, "/judge", authHeader) + if status == 200 { + return util.NewResult(false, "Token \""+authHeader+"\" must not authenticate as an uninitialized judge") + } + } + + return util.ResultOk() +} \ No newline at end of file diff --git a/tests/tests/clock.go b/tests/tests/clock.go new file mode 100644 index 0000000..55e268e --- /dev/null +++ b/tests/tests/clock.go @@ -0,0 +1,184 @@ +package tests + +import ( + "fmt" + "tests/util" +) + +// --- Clock Tests --- + +// ClockPauseUnpause verifies the clock can be paused and then resumed +func ClockPauseUnpause(context *util.Context) util.Result { + // Pause + pauseRes := util.PostRequest(context.Logger, "/admin/clock/pause", nil, util.AdminAuth()) + if !util.IsOk(pauseRes) { + return util.NewResult(false, "Failed to pause clock: "+pauseRes) + } + + clockRes := util.GetRequest(context.Logger, "/admin/clock", util.AdminAuth()) + if !util.IsValue(clockRes, "running", util.BoolType, false) { + return util.NewResult(false, "Clock should be paused (running=false) after pause") + } + + // Unpause + unpauseRes := util.PostRequest(context.Logger, "/admin/clock/unpause", nil, util.AdminAuth()) + if !util.IsOk(unpauseRes) { + return util.NewResult(false, "Failed to unpause clock: "+unpauseRes) + } + + clockRes2 := util.GetRequest(context.Logger, "/admin/clock", util.AdminAuth()) + if !util.IsValue(clockRes2, "running", util.BoolType, true) { + return util.NewResult(false, "Clock should be running (running=true) after unpause") + } + + return util.ResultOk() +} + +// ClockReset verifies the clock returns to a stopped zero state after reset +func ClockReset(context *util.Context) util.Result { + resetRes := util.PostRequest(context.Logger, "/admin/clock/reset", nil, util.AdminAuth()) + if !util.IsOk(resetRes) { + return util.NewResult(false, "Failed to reset clock: "+resetRes) + } + + clockRes := util.GetRequest(context.Logger, "/admin/clock", util.AdminAuth()) + if !util.IsValue(clockRes, "running", util.BoolType, false) { + return util.NewResult(false, "Clock should not be running after reset") + } + if !util.IsValue(clockRes, "time", util.Float64Type, 0.0) { + return util.NewResult(false, "Clock time should be 0 after reset: "+clockRes) + } + + return util.ResultOk() +} + +// AdminStartedReflectsClockState checks /admin/started matches actual clock running state +func AdminStartedReflectsClockState(context *util.Context) util.Result { + // Reset clock to stopped state + util.PostRequest(context.Logger, "/admin/clock/reset", nil, util.AdminAuth()) + + startedRes := util.GetRequest(context.Logger, "/admin/started", util.AdminAuth()) + // When clock is stopped, ok should be 0 + if util.IsOk(startedRes) { + return util.NewResult(false, "/admin/started should return ok=0 when clock is not running") + } + + // Start the clock + util.PostRequest(context.Logger, "/admin/clock/unpause", nil, util.AdminAuth()) + + startedRes2 := util.GetRequest(context.Logger, "/admin/started", util.AdminAuth()) + if !util.IsOk(startedRes2) { + return util.NewResult(false, "/admin/started should return ok=1 when clock is running") + } + + return util.ResultOk() +} + +// --- Admin Options / Settings Tests --- + +// GetOptionsReturnsValidStructure verifies /admin/options returns a well-formed response +func GetOptionsReturnsValidStructure(context *util.Context) util.Result { + res := util.GetRequest(context.Logger, "/admin/options", util.AdminAuth()) + + // Check for a few known fields + if util.ExtractString(res, "switching_mode") == "" && !util.IsValue(res, "min_views", util.IntType, 0) { + return util.NewResult(false, "GET /admin/options response missing expected fields: "+res) + } + + return util.ResultOk() +} + +// SetAndGetJudgingTimer verifies that setting the judging timer persists +func SetAndGetJudgingTimer(context *util.Context) util.Result { + setRes := util.PostRequest(context.Logger, "/admin/options", util.H{ + "judging_timer": 300, + }, util.AdminAuth()) + if !util.IsOk(setRes) { + return util.NewResult(false, "Failed to set judging timer: "+setRes) + } + + // Create a judge for judge auth + judgeToken, result := createNamedJudge(context, "testForTimer@example.com", "Test For Timer") + if !result.Success { + return result + } + + timerRes := util.GetRequest(context.Logger, "/admin/timer", util.JudgeAuth(judgeToken)) + timer := util.ExtractInt(timerRes, "judging_timer") + if timer != 300 { + return util.NewResult(false, fmt.Sprintf("Judging timer should be 300, got %d", timer)) + } + + return util.ResultOk() +} + +// SetMinViews verifies that the min_views setting can be updated +func SetMinViews(context *util.Context) util.Result { + setRes := util.PostRequest(context.Logger, "/admin/options", util.H{ + "min_views": 5, + }, util.AdminAuth()) + if !util.IsOk(setRes) { + return util.NewResult(false, "Failed to set min_views: "+setRes) + } + + optRes := util.GetRequest(context.Logger, "/admin/options", util.AdminAuth()) + if !util.IsValue(optRes, "min_views", util.IntType, 5) { + return util.NewResult(false, "min_views should be 5 after setting: "+optRes) + } + + return util.ResultOk() +} + +// DeliberationToggle verifies deliberation mode can be toggled on and off +func DeliberationToggle(context *util.Context) util.Result { + // Enable deliberation + onRes := util.PostRequest(context.Logger, "/admin/deliberation", util.H{"start": true}, util.AdminAuth()) + if !util.IsOk(onRes) { + return util.NewResult(false, "Failed to enable deliberation: "+onRes) + } + + // Judges should see deliberation mode on + token, result := createNamedJudge(context, "deliberation_test@example.com", "Deliberation Test Judge") + if !result.Success { + return result + } + delibRes := util.GetRequest(context.Logger, "/judge/deliberation", util.JudgeAuth(token)) + if !util.IsOk(delibRes) { + return util.NewResult(false, "Judge should see deliberation mode as active") + } + + // Disable deliberation + offRes := util.PostRequest(context.Logger, "/admin/deliberation", util.H{"start": false}, util.AdminAuth()) + if !util.IsOk(offRes) { + return util.NewResult(false, "Failed to disable deliberation: "+offRes) + } + + delibRes2 := util.GetRequest(context.Logger, "/judge/deliberation", util.JudgeAuth(token)) + if util.IsOk(delibRes2) { + return util.NewResult(false, "Deliberation mode should be off after disabling") + } + + return util.ResultOk() +} + +// BlockReqsToggle verifies the block_reqs setting can be toggled +func BlockReqsToggle(context *util.Context) util.Result { + // Enable blocking + enableRes := util.PostRequest(context.Logger, "/admin/block-reqs", util.H{"block_reqs": true}, util.AdminAuth()) + if !util.IsOk(enableRes) { + return util.NewResult(false, "Failed to enable block_reqs: "+enableRes) + } + + optRes := util.GetRequest(context.Logger, "/admin/options", util.AdminAuth()) + if !util.IsValue(optRes, "block_reqs", util.BoolType, true) { + return util.NewResult(false, "block_reqs should be true after enabling") + } + + // Disable blocking (restore default) + disableRes := util.PostRequest(context.Logger, "/admin/block-reqs", util.H{"block_reqs": false}, util.AdminAuth()) + if !util.IsOk(disableRes) { + return util.NewResult(false, "Failed to disable block_reqs: "+disableRes) + } + + return util.ResultOk() +} \ No newline at end of file diff --git a/tests/tests/judge.go b/tests/tests/judge.go new file mode 100644 index 0000000..8b9d81f --- /dev/null +++ b/tests/tests/judge.go @@ -0,0 +1,377 @@ +package tests + +import ( + "encoding/json" + "fmt" + "tests/util" +) + +// --- Judge CRUD Tests --- + +// AddAndDeleteJudge verifies a judge can be created and then deleted, disappearing from the list +func AddAndDeleteJudge(context *util.Context) util.Result { + // Add a unique judge + email := "delete_test@example.com" + addRes := util.PostRequest(context.Logger, "/judge/new", util.H{ + "name": "Delete Test Judge", + "email": email, + "track": "", + "notes": "", + "no_send": true, + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add judge: "+addRes) + } + + // Find their ID + id, result := findJudgeIDByEmail(context, email) + if !result.Success { + return result + } + + // Delete the judge + delRes := util.DeleteRequest(context.Logger, "/judge/"+id, util.AdminAuth()) + if !util.IsOk(delRes) { + return util.NewResult(false, "Failed to delete judge: "+delRes) + } + + // Confirm they no longer appear in the list + _, result = findJudgeIDByEmail(context, email) + if result.Success { + return util.NewResult(false, "Judge still appears in list after deletion") + } + + return util.ResultOk() +} + +// EditJudge verifies that judge info can be updated and the changes persist +func EditJudge(context *util.Context) util.Result { + email := "edit_test@example.com" + addRes := util.PostRequest(context.Logger, "/judge/new", util.H{ + "name": "Edit Test Judge", + "email": email, + "track": "", + "notes": "original notes", + "no_send": true, + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add judge for edit test: "+addRes) + } + + id, result := findJudgeIDByEmail(context, email) + if !result.Success { + return result + } + + // Edit the judge + editRes := util.PutRequest(context.Logger, "/judge/"+id, util.H{ + "name": "Edited Judge Name", + "email": email, + "notes": "updated notes", + }, util.AdminAuth()) + if !util.IsOk(editRes) { + return util.NewResult(false, "Failed to edit judge: "+editRes) + } + + // Verify the change + listRes := util.GetRequest(context.Logger, "/judge/list", util.AdminAuth()) + name := findJudgeFieldByEmail(listRes, email, "name") + if name != "Edited Judge Name" { + return util.NewResult(false, fmt.Sprintf("Judge name not updated: expected 'Edited Judge Name', got '%s'", name)) + } + + return util.ResultOk() +} + +// JudgeLoginWithValidCode verifies a judge can log in and receive a token +func JudgeLoginWithValidCode(context *util.Context) util.Result { + email := "login_test@example.com" + addRes := util.PostRequest(context.Logger, "/judge/new", util.H{ + "name": "Login Test Judge", + "email": email, + "track": "", + "notes": "", + "no_send": true, + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add judge: "+addRes) + } + + listRes := util.GetRequest(context.Logger, "/judge/list", util.AdminAuth()) + code := extractJudgeCode(listRes, email) + if code == "" { + return util.NewResult(false, "Could not find judge code in list") + } + + loginRes := util.PostRequest(context.Logger, "/judge/login", util.H{"code": code}, util.DefaultAuth()) + token := util.ExtractString(loginRes, "token") + if token == "" { + return util.NewResult(false, "Judge login did not return a token: "+loginRes) + } + + return util.ResultOk() +} + +// JudgeLoginWithInvalidCode verifies that a bad login code is rejected +func JudgeLoginWithInvalidCode(context *util.Context) util.Result { + res := util.PostRequest(context.Logger, "/judge/login", util.H{"code": "DEFINITELY-NOT-A-REAL-CODE"}, util.DefaultAuth()) + return util.AssertNotOk(res, "Login with invalid code should not succeed") +} + +// JudgeWelcomeFlow verifies the read_welcome flag can be set and queried +func JudgeWelcomeFlow(context *util.Context) util.Result { + token, result := createNamedJudge(context, "welcome_test@example.com", "Welcome Test Judge") + if !result.Success { + return result + } + + auth := util.JudgeAuth(token) + + // Initially read_welcome should be false + getRes := util.GetRequest(context.Logger, "/judge/welcome", auth) + if util.IsOk(getRes) { + return util.NewResult(false, "read_welcome should initially be false (not ok)") + } + + // Mark welcome as read + putRes := util.PostRequest(context.Logger, "/judge/welcome", util.H{}, auth) + if !util.IsOk(putRes) { + return util.NewResult(false, "Failed to set read_welcome: "+putRes) + } + + // Now it should return ok + getRes2 := util.GetRequest(context.Logger, "/judge/welcome", auth) + if !util.IsOk(getRes2) { + return util.NewResult(false, "read_welcome should be true after PUT /judge/welcome") + } + + return util.ResultOk() +} + +// HideJudge verifies that hiding a judge marks them as inactive +func HideJudge(context *util.Context) util.Result { + email := "hide_test@example.com" + addRes := util.PostRequest(context.Logger, "/judge/new", util.H{ + "name": "Hide Test Judge", + "email": email, + "track": "", + "notes": "", + "no_send": true, + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add judge: "+addRes) + } + + id, result := findJudgeIDByEmail(context, email) + if !result.Success { + return result + } + + hideRes := util.PutRequest(context.Logger, "/judge/hide/"+id, util.H{"hide": true}, util.AdminAuth()) + if !util.IsOk(hideRes) { + return util.NewResult(false, "Failed to hide judge: "+hideRes) + } + + // Confirm active is now false + listRes := util.GetRequest(context.Logger, "/judge/list", util.AdminAuth()) + active := findJudgeFieldByEmail(listRes, email, "active") + if active != "false" { + return util.NewResult(false, fmt.Sprintf("Judge should be inactive after hiding, got active=%s", active)) + } + + return util.ResultOk() +} + +// JudgeStatsReflectAdditions checks that judge stats update after adding judges +func JudgeStatsReflectAdditions(context *util.Context) util.Result { + // Get current count + statsBefore := util.GetRequest(context.Logger, "/judge/stats", util.AdminAuth()) + numBefore := util.ExtractInt(statsBefore, "num") + + // Add a judge + addRes := util.PostRequest(context.Logger, "/judge/new", util.H{ + "name": "Stats Test Judge", + "email": "stats_judge@example.com", + "track": "", + "notes": "", + "no_send": true, + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add judge: "+addRes) + } + + statsAfter := util.GetRequest(context.Logger, "/judge/stats", util.AdminAuth()) + numAfter := util.ExtractInt(statsAfter, "num") + + if numAfter != numBefore+1 { + return util.NewResult(false, fmt.Sprintf("Judge count should increase by 1: before=%d, after=%d", numBefore, numAfter)) + } + + return util.ResultOk() +} + +// --- Helpers --- + +// createNamedJudge creates a judge and returns their token +func createNamedJudge(context *util.Context, email string, name string) (string, util.Result) { + addRes := util.PostRequest(context.Logger, "/judge/new", util.H{ + "name": name, + "email": email, + "track": "", + "notes": "", + "no_send": true, + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return "", util.NewResult(false, "Failed to create judge '"+name+"': "+addRes) + } + + listRes := util.GetRequest(context.Logger, "/judge/list", util.AdminAuth()) + code := extractJudgeCode(listRes, email) + if code == "" { + return "", util.NewResult(false, "Could not find code for judge: "+email) + } + + loginRes := util.PostRequest(context.Logger, "/judge/login", util.H{"code": code}, util.DefaultAuth()) + token := util.ExtractString(loginRes, "token") + if token == "" { + return "", util.NewResult(false, "Judge login did not return a token for: "+email) + } + + return token, util.ResultOk() +} + +// findJudgeIDByEmail scans the judge list for a judge with the given email and returns their ID +func findJudgeIDByEmail(context *util.Context, email string) (string, util.Result) { + listRes := util.GetRequest(context.Logger, "/judge/list", util.AdminAuth()) + id := findJudgeFieldByEmail(listRes, email, "id") + if id == "" { + return "", util.NewResult(false, "Could not find judge with email '"+email+"' in judge list") + } + return id, util.ResultOk() +} + +// findJudgeFieldByEmail scans a judge list JSON body and returns the value of 'field' for the judge with the given email +func findJudgeFieldByEmail(body string, email string, field string) string { + var judges []map[string]any + if err := json.Unmarshal([]byte(body), &judges); err != nil { + return "" + } + for _, judge := range judges { + if judge["email"] == email { + if val, ok := judge[field]; ok { + return fmt.Sprintf("%v", val) + } + } + } + return "" +} + +// extractJudgeCode returns the login code for a judge identified by email +func extractJudgeCode(body string, email string) string { + return findJudgeFieldByEmail(body, email, "code") +} + +// QRCheckEmptyCodeRejected verifies that /qr/check rejects an empty code. +// Before the fix, options.QRCode defaulted to "" so submitting "" satisfied +// the equality check on a fresh instance with no QR generated yet. +func QRCheckEmptyCodeRejected(context *util.Context) util.Result { + res := util.PostRequest(context.Logger, "/qr/check", util.H{"code": ""}, util.DefaultAuth()) + return util.AssertNotOk(res, "Empty string should not pass QR code check") +} + +// QRCheckTrackEmptyCodeRejected verifies that /qr/check/:track rejects an +// empty code. Track QR codes also default to "" and were equally bypassable. +func QRCheckTrackEmptyCodeRejected(context *util.Context) util.Result { + res := util.PostRequest(context.Logger, "/qr/check/unset-track", util.H{"code": ""}, util.DefaultAuth()) + return util.AssertNotOk(res, "Empty string should not pass track QR code check") +} + +// QRAddEmptyCodeDoesNotCreateJudge verifies that /qr/add rejects an empty +// code and does NOT create a judge. Checks judge count before and after to +// catch a silently-created judge even if the response body looks benign. +func QRAddEmptyCodeDoesNotCreateJudge(context *util.Context) util.Result { + listBefore := util.GetRequest(context.Logger, "/judge/list", util.AdminAuth()) + countBefore := countJudgesInList(listBefore) + + res := util.PostRequest(context.Logger, "/qr/add", util.H{ + "name": "Attacker", + "email": "attacker@evil.com", + "notes": "", + "code": "", + }, util.DefaultAuth()) + + if util.IsOk(res) { + return util.NewResult(false, "POST /qr/add with empty code should be rejected") + } + + listAfter := util.GetRequest(context.Logger, "/judge/list", util.AdminAuth()) + countAfter := countJudgesInList(listAfter) + if countAfter > countBefore { + return util.NewResult(false, "POST /qr/add with empty code must not create a judge — judge count increased") + } + + return util.ResultOk() +} + +// QRAddGarbageCodeDoesNotCreateJudge verifies that a non-empty but invalid +// code is also rejected — guards against a fix that only special-cases "". +func QRAddGarbageCodeDoesNotCreateJudge(context *util.Context) util.Result { + listBefore := util.GetRequest(context.Logger, "/judge/list", util.AdminAuth()) + countBefore := countJudgesInList(listBefore) + + res := util.PostRequest(context.Logger, "/qr/add", util.H{ + "name": "Attacker", + "email": "attacker2@evil.com", + "notes": "", + "code": "THIS-IS-NOT-A-REAL-QR-CODE", + }, util.DefaultAuth()) + + if util.IsOk(res) { + return util.NewResult(false, "POST /qr/add with invalid code should be rejected") + } + + listAfter := util.GetRequest(context.Logger, "/judge/list", util.AdminAuth()) + countAfter := countJudgesInList(listAfter) + if countAfter > countBefore { + return util.NewResult(false, "POST /qr/add with invalid code must not create a judge — judge count increased") + } + + return util.ResultOk() +} + +// QRValidFlowStillWorks verifies the legitimate QR signup flow still works +// after the fix: generate a code as admin, verify it, then use it to add a judge. +func QRValidFlowStillWorks(context *util.Context) util.Result { + // POST /admin/qr returns {"qr_code":"..."} directly, not {"ok":1} + genRes := util.PostRequest(context.Logger, "/admin/qr", nil, util.AdminAuth()) + qrCode := util.ExtractString(genRes, "qr_code") + if qrCode == "" { + return util.NewResult(false, "POST /admin/qr did not return a qr_code: "+genRes) + } + + checkRes := util.PostRequest(context.Logger, "/qr/check", util.H{"code": qrCode}, util.DefaultAuth()) + if !util.IsOk(checkRes) { + return util.NewResult(false, "Valid QR code should pass /qr/check: "+checkRes) + } + + addRes := util.PostRequest(context.Logger, "/qr/add", util.H{ + "name": "Legitimate QR Judge", + "email": "qr_judge@example.com", + "notes": "", + "code": qrCode, + "no_send": true, + }, util.DefaultAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "POST /qr/add with valid code should succeed: "+addRes) + } + + return util.ResultOk() +} + +func countJudgesInList(body string) int { + var judges []map[string]any + if err := jsonUnmarshalList(body, &judges); err != nil { + return 0 + } + return len(judges) +} \ No newline at end of file diff --git a/tests/tests/judging.go b/tests/tests/judging.go new file mode 100644 index 0000000..3c62f2c --- /dev/null +++ b/tests/tests/judging.go @@ -0,0 +1,412 @@ +package tests + +import ( + "fmt" + "tests/util" +) + +// --- Judging Workflow Tests --- + +// JudgingTestSetup will set the setting that may break a standard workflow +// This includes actually starting judging and disabling groups + tracks +func JudgingTestSetup(context *util.Context) util.Result { + // Disable group/track judging + setRes := util.PostRequest(context.Logger, "/admin/options", util.H{ + "judge_tracks": false, + "multi_group": false, + }, util.AdminAuth()) + if !util.IsOk(setRes) { + return util.NewResult(false, "Failed to set judge-tracks and multi_group: "+setRes) + } + + // Unpause clock (to make sure judging starts) + unpauseRes := util.PostRequest(context.Logger, "/admin/clock/unpause", nil, util.AdminAuth()) + if !util.IsOk(unpauseRes) { + return util.NewResult(false, "Failed to unpause clock: "+unpauseRes) + } + + return util.ResultOk() +} + +// JudgingStandardPath runs through the core judging loop: +// create judge + projects → login → get next → finish → verify seen counts increment +func JudgingStandardPath(context *util.Context) util.Result { + // Delete all projects + setRes := util.PostRequest(context.Logger, "/admin/reset", util.H{ + "type": "projects", + }, util.AdminAuth()) + if !util.IsOk(setRes) { + return util.NewResult(false, "Failed to set judge-tracks and multi_group: "+setRes) + } + + // Add two projects + for i := 1; i <= 2; i++ { + addRes := util.PostRequest(context.Logger, "/project/new", util.H{ + "name": fmt.Sprintf("Judging Project %d", i), + "description": "Test project for judging flow", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, fmt.Sprintf("Failed to add judging project %d: %s", i, addRes)) + } + } + + // Create a judge and log in + token, result := createNamedJudge(context, "judging_flow@example.com", "Judging Flow Judge") + if !result.Success { + return result + } + auth := util.JudgeAuth(token) + + // Get project count before + statsBefore := util.GetRequest(context.Logger, "/project/stats", util.AdminAuth()) + avgSeenBefore := util.ExtractInt(statsBefore, "avg_seen") + + // Get next project + nextRes := util.PostRequest(context.Logger, "/judge/next", nil, auth) + projectID := util.ExtractString(nextRes, "project_id") + if projectID == "" { + return util.NewResult(false, "GET /judge/next did not return a project_id: "+nextRes) + } + + // Finish judging that project + finishRes := util.PostRequest(context.Logger, "/judge/finish", util.H{ + "notes": "Looks great", + "starred": false, + }, auth) + if !util.IsOk(finishRes) { + return util.NewResult(false, "POST /judge/finish failed: "+finishRes) + } + + // Verify avg_seen increased (or at minimum, seen count on the judge went up) + judgeRes := util.GetRequest(context.Logger, "/judge", auth) + seen := util.ExtractInt(judgeRes, "seen") + if seen < 1 { + return util.NewResult(false, fmt.Sprintf("Judge 'seen' count should be at least 1 after judging, got %d", seen)) + } + + _ = avgSeenBefore // avoid unused variable error; avg_seen may not change with just one judge + + return util.ResultOk() +} + +// JudgeDoesNotRepeatProjects verifies a judge never gets the same project twice +func JudgeDoesNotRepeatProjects(context *util.Context) util.Result { + // Delete all projects + setRes := util.PostRequest(context.Logger, "/admin/reset", util.H{ + "type": "projects", + }, util.AdminAuth()) + if !util.IsOk(setRes) { + return util.NewResult(false, "Failed to set judge-tracks and multi_group: "+setRes) + } + + // Add 3 projects + for i := 1; i <= 3; i++ { + util.PostRequest(context.Logger, "/project/new", util.H{ + "name": fmt.Sprintf("No Repeat Project %d", i), + "description": "Uniqueness test", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + } + + token, result := createNamedJudge(context, "no_repeat@example.com", "No Repeat Judge") + if !result.Success { + return result + } + auth := util.JudgeAuth(token) + + seen := map[string]bool{} + + for i := 0; i < 3; i++ { + nextRes := util.PostRequest(context.Logger, "/judge/next", nil, auth) + projectID := util.ExtractString(nextRes, "project_id") + if projectID == "" { + // No more projects — that's fine, stop early + break + } + if seen[projectID] { + return util.NewResult(false, fmt.Sprintf("Judge was assigned the same project twice: %s", projectID)) + } + seen[projectID] = true + + finishRes := util.PostRequest(context.Logger, "/judge/finish", util.H{ + "notes": "", + "starred": false, + }, auth) + if !util.IsOk(finishRes) { + return util.NewResult(false, "POST /judge/finish failed on iteration "+fmt.Sprint(i)) + } + } + + return util.ResultOk() +} + +// SkipProjectCreatesFlag verifies that skipping a project records a flag visible to admin +func SkipProjectCreatesFlag(context *util.Context) util.Result { + // Delete all projects + setRes := util.PostRequest(context.Logger, "/admin/reset", util.H{ + "type": "projects", + }, util.AdminAuth()) + if !util.IsOk(setRes) { + return util.NewResult(false, "Failed to set judge-tracks and multi_group: "+setRes) + } + + // Add a project to skip + util.PostRequest(context.Logger, "/project/new", util.H{ + "name": "Skip Test Project", + "description": "Will be skipped", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + + token, result := createNamedJudge(context, "skip_test@example.com", "Skip Test Judge") + if !result.Success { + return result + } + auth := util.JudgeAuth(token) + + // Get count of flags before + flagsBefore := util.GetRequest(context.Logger, "/admin/flags", util.AdminAuth()) + countBefore := countFlags(flagsBefore) + + // Get next project + nextRes := util.PostRequest(context.Logger, "/judge/next", nil, auth) + projectID := util.ExtractString(nextRes, "project_id") + if projectID == "" { + return util.NewResult(false, "No project returned for skip test: "+nextRes) + } + + // Skip the project + skipRes := util.PostRequest(context.Logger, "/judge/skip", util.H{ + "reason": "absent", + }, auth) + if !util.IsOk(skipRes) { + return util.NewResult(false, "POST /judge/skip failed: "+skipRes) + } + + // Verify flag count increased + flagsAfter := util.GetRequest(context.Logger, "/admin/flags", util.AdminAuth()) + countAfter := countFlags(flagsAfter) + + if countAfter <= countBefore { + return util.NewResult(false, fmt.Sprintf("Flag count should increase after skip: before=%d, after=%d", countBefore, countAfter)) + } + + return util.ResultOk() +} + +// JudgeRankProjects verifies that submitting a ranking succeeds +func JudgeRankProjects(context *util.Context) util.Result { + // Delete all projects + setRes := util.PostRequest(context.Logger, "/admin/reset", util.H{ + "type": "projects", + }, util.AdminAuth()) + if !util.IsOk(setRes) { + return util.NewResult(false, "Failed to set judge-tracks and multi_group: "+setRes) + } + + // Add projects + var projectIDs []string + for i := 1; i <= 2; i++ { + name := fmt.Sprintf("Rank Test Project %d", i) + util.PostRequest(context.Logger, "/project/new", util.H{ + "name": name, + "description": "For ranking", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + + id, result := findProjectIDByName(context, name) + if !result.Success { + return result + } + projectIDs = append(projectIDs, id) + } + + token, result := createNamedJudge(context, "rank_test@example.com", "Rank Test Judge") + if !result.Success { + return result + } + auth := util.JudgeAuth(token) + + // Judge must see projects before ranking — do 2 finish cycles + for i := 0; i < 2; i++ { + nextRes := util.PostRequest(context.Logger, "/judge/next", nil, auth) + if util.ExtractString(nextRes, "project_id") == "" { + break + } + util.PostRequest(context.Logger, "/judge/finish", util.H{"notes": "", "starred": false}, auth) + } + + // Submit a ranking + rankRes := util.PostRequest(context.Logger, "/judge/rank", util.H{ + "ranking": projectIDs, + }, auth) + if !util.IsOk(rankRes) { + return util.NewResult(false, "POST /judge/rank failed: "+rankRes) + } + + return util.ResultOk() +} + +// StarProject verifies a judge can star and unstar a project +func StarProject(context *util.Context) util.Result { + // Delete all projects + setRes := util.PostRequest(context.Logger, "/admin/reset", util.H{ + "type": "projects", + }, util.AdminAuth()) + if !util.IsOk(setRes) { + return util.NewResult(false, "Failed to set judge-tracks and multi_group: "+setRes) + } + + util.PostRequest(context.Logger, "/project/new", util.H{ + "name": "Star Test Project", + "description": "Will be starred", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + + token, result := createNamedJudge(context, "star_test@example.com", "Star Test Judge") + if !result.Success { + return result + } + auth := util.JudgeAuth(token) + + nextRes := util.PostRequest(context.Logger, "/judge/next", nil, auth) + projectID := util.ExtractString(nextRes, "project_id") + if projectID == "" { + return util.NewResult(false, "No project returned for star test") + } + + // Finish with starred = true + finishRes := util.PostRequest(context.Logger, "/judge/finish", util.H{ + "notes": "", + "starred": true, + }, auth) + if !util.IsOk(finishRes) { + return util.NewResult(false, "POST /judge/finish (starred) failed: "+finishRes) + } + + // Verify via /judge/projects that starred is true + projsRes := util.GetRequest(context.Logger, "/judge/projects", auth) + starred := findSeenProjectField(projsRes, projectID, "starred") + if starred != "true" { + return util.NewResult(false, fmt.Sprintf("Project should be starred, got '%s'", starred)) + } + + return util.ResultOk() +} + +// JudgeNotesUpdate verifies that notes can be updated for a seen project +func JudgeNotesUpdate(context *util.Context) util.Result { + // Delete all projects + setRes := util.PostRequest(context.Logger, "/admin/reset", util.H{ + "type": "projects", + }, util.AdminAuth()) + if !util.IsOk(setRes) { + return util.NewResult(false, "Failed to set judge-tracks and multi_group: "+setRes) + } + + util.PostRequest(context.Logger, "/project/new", util.H{ + "name": "Notes Test Project", + "description": "Will have notes", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + + token, result := createNamedJudge(context, "notes_test@example.com", "Notes Test Judge") + if !result.Success { + return result + } + auth := util.JudgeAuth(token) + + nextRes := util.PostRequest(context.Logger, "/judge/next", nil, auth) + projectID := util.ExtractString(nextRes, "project_id") + if projectID == "" { + return util.NewResult(false, "No project returned for notes test") + } + + util.PostRequest(context.Logger, "/judge/finish", util.H{"notes": "initial note", "starred": false}, auth) + + // Update notes + notesRes := util.PutRequest(context.Logger, "/judge/notes/"+projectID, util.H{"notes": "updated note"}, auth) + if !util.IsOk(notesRes) { + return util.NewResult(false, "PUT /judge/notes/:id failed: "+notesRes) + } + + // Verify + projsRes := util.GetRequest(context.Logger, "/judge/projects", auth) + notes := findSeenProjectField(projsRes, projectID, "notes") + if notes != "updated note" { + return util.NewResult(false, fmt.Sprintf("Notes not updated: got '%s'", notes)) + } + + return util.ResultOk() +} + +// JudgeNextWithNoProjects verifies the API handles the case gracefully (no 500) +func JudgeNextWithNoActiveProjects(context *util.Context) util.Result { + // Delete all projects + setRes := util.PostRequest(context.Logger, "/admin/reset", util.H{ + "type": "projects", + }, util.AdminAuth()) + if !util.IsOk(setRes) { + return util.NewResult(false, "Failed to set judge-tracks and multi_group: "+setRes) + } + + // Create a fresh judge + token, result := createNamedJudge(context, "empty_test@example.com", "Empty Test Judge") + if !result.Success { + return result + } + auth := util.JudgeAuth(token) + + status, _ := util.PostRequestWithStatus(context.Logger, "/judge/next", nil, auth) + if status == 500 { + return util.NewResult(false, "GET /judge/next with no projects should not return 500") + } + + return util.ResultOk() +} + +// --- Helpers --- + +// countFlags counts the number of flag objects in a JSON array response +func countFlags(body string) int { + var flags []map[string]any + if err := jsonUnmarshalList(body, &flags); err != nil { + return 0 + } + return len(flags) +} + +// findSeenProjectField finds a field in the /judge/projects list by project_id +func findSeenProjectField(body string, projectID string, field string) string { + var projects []map[string]any + if err := jsonUnmarshalList(body, &projects); err != nil { + return "" + } + for _, p := range projects { + id, _ := p["project_id"].(string) + if id == projectID { + if val, ok := p[field]; ok { + return fmt.Sprintf("%v", val) + } + } + } + return "" +} \ No newline at end of file diff --git a/tests/tests/project.go b/tests/tests/project.go new file mode 100644 index 0000000..c9f68ea --- /dev/null +++ b/tests/tests/project.go @@ -0,0 +1,273 @@ +package tests + +import ( + "encoding/json" + "fmt" + "tests/util" +) + +// --- Project CRUD Tests --- + +// AddProject verifies a project can be added successfully +func AddProject(context *util.Context) util.Result { + res := util.PostRequest(context.Logger, "/project/new", util.H{ + "name": "Test Project", + "description": "A test project", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + return util.AssertOk(res, "Failed to add project: "+res) +} + +// AddAndDeleteProject verifies a project can be created and then deleted +func AddAndDeleteProject(context *util.Context) util.Result { + name := "Delete Me Project" + addRes := util.PostRequest(context.Logger, "/project/new", util.H{ + "name": name, + "description": "Project to be deleted", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add project: "+addRes) + } + + id, result := findProjectIDByName(context, name) + if !result.Success { + return result + } + + delRes := util.DeleteRequest(context.Logger, "/project/"+id, util.AdminAuth()) + if !util.IsOk(delRes) { + return util.NewResult(false, "Failed to delete project: "+delRes) + } + + // Confirm it's gone + _, result = findProjectIDByName(context, name) + if result.Success { + return util.NewResult(false, "Project still appears in list after deletion") + } + + return util.ResultOk() +} + +// DeleteNonExistentProject verifies that deleting a bogus ID doesn't cause a 500 +func DeleteNonExistentProject(context *util.Context) util.Result { + status, _ := util.DeleteRequestWithStatus(context.Logger, "/project/000000000000000000000000", util.AdminAuth()) + if status == 500 { + return util.NewResult(false, "Deleting a non-existent project should not return 500") + } + return util.ResultOk() +} + +// EditProject verifies project fields can be updated +func EditProject(context *util.Context) util.Result { + name := "Edit Me Project" + addRes := util.PostRequest(context.Logger, "/project/new", util.H{ + "name": name, + "description": "Original description", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add project: "+addRes) + } + + id, result := findProjectIDByName(context, name) + if !result.Success { + return result + } + + editRes := util.PutRequest(context.Logger, "/project/"+id, util.H{ + "name": "Edited Project Name", + "description": "Updated description", + "url": "https://updated.example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + if !util.IsOk(editRes) { + return util.NewResult(false, "Failed to edit project: "+editRes) + } + + // Verify the change + listRes := util.GetRequest(context.Logger, "/project/list", util.AdminAuth()) + desc := findProjectFieldByName(listRes, "Edited Project Name", "description") + if desc != "Updated description" { + return util.NewResult(false, fmt.Sprintf("Project description not updated: got '%s'", desc)) + } + + return util.ResultOk() +} + +// HideProject verifies that hiding a project marks it inactive and removes it from the public list +func HideProject(context *util.Context) util.Result { + name := "Hide Me Project" + addRes := util.PostRequest(context.Logger, "/project/new", util.H{ + "name": name, + "description": "Will be hidden", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add project: "+addRes) + } + + id, result := findProjectIDByName(context, name) + if !result.Success { + return result + } + + hideRes := util.PutRequest(context.Logger, "/project/hide/"+id, util.H{"hide": true}, util.AdminAuth()) + if !util.IsOk(hideRes) { + return util.NewResult(false, "Failed to hide project: "+hideRes) + } + + // Check that it appears in the project list as hidden + projectListRes := util.GetRequest(context.Logger, "/project/list", util.AdminAuth()) + projectActive := findProjectFieldByName(projectListRes, name, "active") + if projectActive != "false" { + return util.NewResult(false, "Hidden project should have active field set to false") + } + + return util.ResultOk() +} + +// ProjectStatsReflectAdditions verifies that project stats update after adding a project +func ProjectStatsReflectAdditions(context *util.Context) util.Result { + statsBefore := util.GetRequest(context.Logger, "/project/stats", util.AdminAuth()) + numBefore := util.ExtractInt(statsBefore, "num") + + addRes := util.PostRequest(context.Logger, "/project/new", util.H{ + "name": "Stats Count Project", + "description": "Counting project", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add project: "+addRes) + } + + statsAfter := util.GetRequest(context.Logger, "/project/stats", util.AdminAuth()) + numAfter := util.ExtractInt(statsAfter, "num") + + if numAfter != numBefore+1 { + return util.NewResult(false, fmt.Sprintf("Project count should increase by 1: before=%d, after=%d", numBefore, numAfter)) + } + + return util.ResultOk() +} + +// PrioritizeProject verifies a project can be prioritized and the flag persists +func PrioritizeProject(context *util.Context) util.Result { + name := "Prioritize Me Project" + addRes := util.PostRequest(context.Logger, "/project/new", util.H{ + "name": name, + "description": "Should be prioritized", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add project: "+addRes) + } + + id, result := findProjectIDByName(context, name) + if !result.Success { + return result + } + + prioRes := util.PutRequest(context.Logger, "/project/prioritize/"+id, util.H{"prioritize": true}, util.AdminAuth()) + if !util.IsOk(prioRes) { + return util.NewResult(false, "Failed to prioritize project: "+prioRes) + } + + // Confirm prioritized is true in the admin list + listRes := util.GetRequest(context.Logger, "/project/list", util.AdminAuth()) + prioritized := findProjectFieldByName(listRes, name, "prioritized") + if prioritized != "true" { + return util.NewResult(false, fmt.Sprintf("Project should be prioritized, got '%s'", prioritized)) + } + + return util.ResultOk() +} + +// MoveProject verifies a project can be moved to a specific table number +func MoveProject(context *util.Context) util.Result { + name := "Move Me Project" + addRes := util.PostRequest(context.Logger, "/project/new", util.H{ + "name": name, + "description": "Will be moved", + "url": "https://example.com", + "try_link": "", + "video_link": "", + "challenge_list": "", + }, util.AdminAuth()) + if !util.IsOk(addRes) { + return util.NewResult(false, "Failed to add project: "+addRes) + } + + id, result := findProjectIDByName(context, name) + if !result.Success { + return result + } + + moveRes := util.PutRequest(context.Logger, "/project/move/"+id, util.H{"location": 999}, util.AdminAuth()) + if !util.IsOk(moveRes) { + return util.NewResult(false, "Failed to move project: "+moveRes) + } + + listRes := util.GetRequest(context.Logger, "/project/list", util.AdminAuth()) + location := findProjectFieldByName(listRes, name, "location") + if location != "999" { + return util.NewResult(false, fmt.Sprintf("Project location should be 999, got '%s'", location)) + } + + return util.ResultOk() +} + +// PublicProjectListIsAccessible verifies the public endpoint works without auth +func PublicProjectListIsAccessible(context *util.Context) util.Result { + status, _ := util.GetRequestWithStatus(context.Logger, "/project/list/public", util.DefaultAuth()) + if status != 200 { + return util.NewResult(false, fmt.Sprintf("Public project list should be accessible without auth, got %d", status)) + } + return util.ResultOk() +} + +// --- Helpers --- + +func findProjectIDByName(context *util.Context, name string) (string, util.Result) { + listRes := util.GetRequest(context.Logger, "/project/list", util.AdminAuth()) + id := findProjectFieldByName(listRes, name, "id") + if id == "" { + return "", util.NewResult(false, "Could not find project with name '"+name+"'") + } + return id, util.ResultOk() +} + +func findProjectFieldByName(body string, name string, field string) string { +var projects []map[string]any + if err := json.Unmarshal([]byte(body), &projects); err != nil { + return "" + } + for _, project := range projects { + if project["name"] == name { + if val, ok := project[field]; ok { + return fmt.Sprintf("%v", val) + } + } + } + return "" +} \ No newline at end of file diff --git a/tests/tests/smoke.go b/tests/tests/smoke.go new file mode 100644 index 0000000..6b0f1a3 --- /dev/null +++ b/tests/tests/smoke.go @@ -0,0 +1,22 @@ +package tests + +import "tests/util" + +// Heartbeat verifies the backend is reachable and returning a healthy response. +func Heartbeat(context *util.Context) util.Result { + res := util.GetRequest(context.Logger, "/", util.DefaultAuth()) + return util.AssertOk(res, "Error with heartbeat endpoint") +} + +// SmokeAdminLogin verifies the admin login endpoint accepts the correct password. +// If this fails, every test that depends on admin auth will also fail. +func SmokeAdminLogin(context *util.Context) util.Result { + res := util.PostRequest(context.Logger, "/admin/login", util.H{"password": util.GetEnv("ADMIN_PASSWORD")}, util.DefaultAuth()) + return util.AssertOk(res, "Admin login failed — check ADMIN_PASSWORD env var and backend config") +} + +// SmokeAdminAuth verifies the Basic auth middleware is functioning correctly. +func SmokeAdminAuth(context *util.Context) util.Result { + res := util.PostRequest(context.Logger, "/admin/auth", nil, util.AdminAuth()) + return util.AssertOk(res, "Admin Basic auth failed — middleware may be broken") +} \ No newline at end of file diff --git a/tests/tests/unittests.go b/tests/tests/unittests.go deleted file mode 100644 index c11431a..0000000 --- a/tests/tests/unittests.go +++ /dev/null @@ -1,44 +0,0 @@ -package tests - -import ( - "tests/src" -) - -func Heartbeat(context *src.Context) src.Result { - res := src.GetRequest(context.Logger, "/", src.DefaultAuth()) - return src.AssertOk(res, "Error with heartbeat endpoint") -} - -func InvalidAdminLogin(context *src.Context) src.Result { - res := src.PostRequest(context.Logger, "/admin/login", src.H{"password": "THIS IS DEFINITELY THE WRONG PASSWORD"}, src.DefaultAuth()) - return src.AssertNotOk(res, "Invalid login to admin account should not be successful") -} - -func AdminLogin(context *src.Context) src.Result { - password := src.GetEnv("ADMIN_PASSWORD") - res := src.PostRequest(context.Logger, "/admin/login", src.H{"password": password}, src.DefaultAuth()) - return src.AssertOk(res, "Error logging in as admin") -} - -func InvalidAdminAuth(context *src.Context) src.Result { - res := src.PostRequest(context.Logger, "/admin/auth", nil, "Bearer INVALID_TOKEN") - return src.AssertNotOk(res, "Invalid auth token should not be successful") -} - -func AdminAuth(context *src.Context) src.Result { - res := src.PostRequest(context.Logger, "/admin/auth", nil, src.AdminAuth()) - return src.AssertOk(res, "Error authenticating as admin") -} - -func AddJudge(context *src.Context) src.Result { - res := src.PostRequest(context.Logger, "/judge/new", src.H{"name": "Test Judge", "email": "test@gmail.com", "track": "", "notes": "test", "no_send": true}, src.AdminAuth()) - return src.AssertOk(res, "Error adding a judge") -} - -func GetClock(context *src.Context) src.Result { - res := src.GetRequest(context.Logger, "/admin/clock", src.AdminAuth()) - if !src.IsValue(res, "running", src.BoolType, false) || !src.IsValue(res, "time", src.Float64Type, 0.0) { - return src.NewResult(false, "Error getting the clock") - } - return src.ResultOk() -} diff --git a/tests/src/context.go b/tests/util/context.go similarity index 89% rename from tests/src/context.go rename to tests/util/context.go index 1ae3704..943f51a 100644 --- a/tests/src/context.go +++ b/tests/util/context.go @@ -1,4 +1,4 @@ -package src +package util import "go.mongodb.org/mongo-driver/mongo" diff --git a/tests/src/db.go b/tests/util/db.go similarity index 99% rename from tests/src/db.go rename to tests/util/db.go index 45d4dbb..3b4e3a7 100644 --- a/tests/src/db.go +++ b/tests/util/db.go @@ -1,4 +1,4 @@ -package src +package util import ( "context" diff --git a/tests/src/logging.go b/tests/util/logging.go similarity index 98% rename from tests/src/logging.go rename to tests/util/logging.go index 37f4b07..54f027a 100644 --- a/tests/src/logging.go +++ b/tests/util/logging.go @@ -1,4 +1,4 @@ -package src +package util import ( "fmt" diff --git a/tests/util/requests.go b/tests/util/requests.go new file mode 100644 index 0000000..4991547 --- /dev/null +++ b/tests/util/requests.go @@ -0,0 +1,291 @@ +package util + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/valyala/fastjson" +) + +type H map[string]any + +// WaitForBackend will wait for the backend to load, checking the URL every 5 seconds +func WaitForBackend(logger *Logger) error { + logger.Log(Info, "Waiting for backend to load...\n") + + url := getBaseUrl() + retry := 3 // Retry 3 times before giving up + + for { + res, err := http.Get(url) + if err != nil { + if retry < 0 { + logger.Log(Error, "Error: failed to connect to backend. Aborting.") + return errors.New("failed to connect to backend") + } + + logger.Log(Info, "Error sending GET request to %s, waiting 5 seconds: %s\n", url, err.Error()) + time.Sleep(5 * time.Second) + retry-- + continue + } + + if res.StatusCode == http.StatusOK { + logger.LogLn(Info, "Backend loaded") + break + } + } + return nil +} + +func GetRequest(logger *Logger, url string, authHeader string) string { + _, body := GetRequestWithStatus(logger, url, authHeader) + return body +} + +// GetRequestWithStatus sends a GET request and returns both the status code and body +func GetRequestWithStatus(logger *Logger, url string, authHeader string) (int, string) { + logger.Log(Verbose, "Sending GET request to %s\n", url) + + fullUrl := getBaseUrl() + url + + req, err := http.NewRequest("GET", fullUrl, nil) + if err != nil { + logger.Log(Error, "Error creating GET request to %s: %s\n", url, err.Error()) + return 0, err.Error() + } + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + logger.Log(Error, "Error sending GET request to %s: %s\n", url, err.Error()) + return 0, err.Error() + } + + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + logger.Log(Error, "Error reading response body: %s\n", err.Error()) + return res.StatusCode, err.Error() + } + + logger.Log(Verbose, "Response (%d): %s\n", res.StatusCode, string(resBody)) + + return res.StatusCode, string(resBody) +} + +func PostRequest(logger *Logger, url string, body H, authHeader string) string { + _, resBody := PostRequestWithStatus(logger, url, body, authHeader) + return resBody +} + +// PostRequestWithStatus sends a POST request and returns both the status code and body +func PostRequestWithStatus(logger *Logger, url string, body H, authHeader string) (int, string) { + logger.Log(Verbose, "Sending POST request to %s\n", url) + + jsonBody, err := json.Marshal(body) + if err != nil { + logger.Log(Error, "Error marshalling body of POST request to %s: %s\n", url, err.Error()) + return 0, err.Error() + } + fullUrl := getBaseUrl() + url + + logger.Log(Verbose, "Request Body: %s\n", jsonBody) + + req, err := http.NewRequest("POST", fullUrl, bytes.NewBuffer(jsonBody)) + if err != nil { + logger.Log(Error, "Error creating POST request to %s: %s\n", url, err.Error()) + return 0, err.Error() + } + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + logger.Log(Error, "Error sending POST request to %s: %s\n", url, err.Error()) + return 0, err.Error() + } + + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + logger.Log(Error, "Error reading response body: %s\n", err.Error()) + return res.StatusCode, err.Error() + } + + logger.Log(Verbose, "Response (%d): %s\n", res.StatusCode, string(resBody)) + + return res.StatusCode, string(resBody) +} + +// PutRequest sends a PUT request and returns the response body +func PutRequest(logger *Logger, url string, body H, authHeader string) string { + _, resBody := PutRequestWithStatus(logger, url, body, authHeader) + return resBody +} + +// PutRequestWithStatus sends a PUT request and returns both the status code and body +func PutRequestWithStatus(logger *Logger, url string, body H, authHeader string) (int, string) { + logger.Log(Verbose, "Sending PUT request to %s\n", url) + + jsonBody, err := json.Marshal(body) + if err != nil { + logger.Log(Error, "Error marshalling body of PUT request to %s: %s\n", url, err.Error()) + return 0, err.Error() + } + fullUrl := getBaseUrl() + url + + logger.Log(Verbose, "Request Body: %s\n", jsonBody) + + req, err := http.NewRequest("PUT", fullUrl, bytes.NewBuffer(jsonBody)) + if err != nil { + logger.Log(Error, "Error creating PUT request to %s: %s\n", url, err.Error()) + return 0, err.Error() + } + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + logger.Log(Error, "Error sending PUT request to %s: %s\n", url, err.Error()) + return 0, err.Error() + } + + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + logger.Log(Error, "Error reading response body: %s\n", err.Error()) + return res.StatusCode, err.Error() + } + + logger.Log(Verbose, "Response (%d): %s\n", res.StatusCode, string(resBody)) + + return res.StatusCode, string(resBody) +} + +// DeleteRequest sends a DELETE request and returns the response body +func DeleteRequest(logger *Logger, url string, authHeader string) string { + _, resBody := DeleteRequestWithStatus(logger, url, authHeader) + return resBody +} + +// DeleteRequestWithStatus sends a DELETE request and returns both the status code and body +func DeleteRequestWithStatus(logger *Logger, url string, authHeader string) (int, string) { + logger.Log(Verbose, "Sending DELETE request to %s\n", url) + + fullUrl := getBaseUrl() + url + + req, err := http.NewRequest("DELETE", fullUrl, nil) + if err != nil { + logger.Log(Error, "Error creating DELETE request to %s: %s\n", url, err.Error()) + return 0, err.Error() + } + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + logger.Log(Error, "Error sending DELETE request to %s: %s\n", url, err.Error()) + return 0, err.Error() + } + + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + logger.Log(Error, "Error reading response body: %s\n", err.Error()) + return res.StatusCode, err.Error() + } + + logger.Log(Verbose, "Response (%d): %s\n", res.StatusCode, string(resBody)) + + return res.StatusCode, string(resBody) +} + +func getBaseUrl() string { + return GetEnv("API_URL") +} + +func DefaultAuth() string { + return "" +} + +func JudgeAuth(token string) string { + return "Bearer " + token +} + +func AdminAuth() string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte("admin:"+GetEnv("ADMIN_PASSWORD"))) +} + +// IsOk checks if body is okay +func IsOk(body string) bool { + return strings.Contains(body, "\"ok\":1") +} + +// AssertOk checks if body is okay and returns a Result +func AssertOk(body string, err string) Result { + if IsOk(body) { + return ResultOk() + } + return NewResult(false, err) +} + +// AssertNotOk checks if body is not okay and returns a Result +func AssertNotOk(body string, err string) Result { + if !IsOk(body) { + return ResultOk() + } + return NewResult(false, err) +} + +// AssertStatus checks that the given status code matches expected and returns a Result +func AssertStatus(actual int, expected int, err string) Result { + if actual == expected { + return ResultOk() + } + return NewResult(false, err) +} + +type JsonType int + +const ( + StringType JsonType = iota + IntType + BoolType + Float64Type +) + +// IsValue checks if body contains key with value +func IsValue(body string, key string, valueType JsonType, value any) bool { + data := []byte(body) + + switch valueType { + case StringType: + return fastjson.GetString(data, key) == value + case IntType: + return fastjson.GetInt(data, key) == value + case BoolType: + return fastjson.GetBool(data, key) == value + case Float64Type: + return fastjson.GetFloat64(data, key) == value + default: + return false + } +} + +// ExtractString extracts a string field from a JSON response body +func ExtractString(body string, key string) string { + return fastjson.GetString([]byte(body), key) +} + +// ExtractInt extracts an int field from a JSON response body +func ExtractInt(body string, key string) int { + return fastjson.GetInt([]byte(body), key) +} \ No newline at end of file diff --git a/tests/src/result.go b/tests/util/result.go similarity index 95% rename from tests/src/result.go rename to tests/util/result.go index 0aa227d..017ced7 100644 --- a/tests/src/result.go +++ b/tests/util/result.go @@ -1,4 +1,4 @@ -package src +package util type Result struct { Success bool diff --git a/tests/src/util.go b/tests/util/util.go similarity index 98% rename from tests/src/util.go rename to tests/util/util.go index 6a536d4..8b4f939 100644 --- a/tests/src/util.go +++ b/tests/util/util.go @@ -1,4 +1,4 @@ -package src +package util import ( "log"