Skip to content

Commit

Permalink
fix: Add submissions limits (#140)
Browse files Browse the repository at this point in the history
* fix(submissions): Rate limit students submissions

* refactor: Remove files deletion error logs table

* fix(submissions): Validate if the laboratory is open

* test(submissions): Add test to ensure students can receive real time updates about their submissions
  • Loading branch information
PedroChaparro authored Jan 7, 2024
1 parent f7d1511 commit b2255a1
Show file tree
Hide file tree
Showing 12 changed files with 251 additions and 34 deletions.
23 changes: 23 additions & 0 deletions __tests__/integration/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/UPB-Code-Labs/main-api/src/accounts/infrastructure/requests"
configInfrastructure "github.com/UPB-Code-Labs/main-api/src/config/infrastructure"
sharedInfrastructure "github.com/UPB-Code-Labs/main-api/src/shared/infrastructure"
submissionsImplementations "github.com/UPB-Code-Labs/main-api/src/submissions/infrastructure/implementations"
"github.com/gin-gonic/gin"
)

Expand Down Expand Up @@ -42,6 +43,13 @@ func TestMain(m *testing.M) {
setupDatabase()
defer sharedInfrastructure.ClosePostgresConnection()

// Setup RabbitMQ
setupRabbitMQ()
defer sharedInfrastructure.CloseRabbitMQConnection()

// Setup SSE
setupSSE()

// Setup http router
setupRouter()
registerBaseAccounts()
Expand All @@ -56,6 +64,21 @@ func setupDatabase() {
configInfrastructure.RunMigrations()
}

func setupRabbitMQ() {
// Connect to RabbitMQ
sharedInfrastructure.ConnectToRabbitMQ()

// Start listening for messages in the submissions real time updates queue
submissionsRealTimeUpdatesQueueMgr := submissionsImplementations.GetSubmissionsRealTimeUpdatesQueueMgrInstance()
go submissionsRealTimeUpdatesQueueMgr.ListenForUpdates()
}

func setupSSE() {
// Start listening for SSE connections
realTimeSubmissionsUpdatesSender := submissionsImplementations.GetSubmissionsRealTimeUpdatesSenderInstance()
go realTimeSubmissionsUpdatesSender.Listen()
}

func setupRouter() {
router = configInfrastructure.InstanceHttpServer()
}
Expand Down
69 changes: 69 additions & 0 deletions __tests__/integration/submissions_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package integration

import (
"bufio"
"encoding/json"
"net/http"
"strings"
"testing"

submissionsDTOs "github.com/UPB-Code-Labs/main-api/src/submissions/domain/dtos"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -87,4 +91,69 @@ func TestSubmitSolutionToTestBlock(t *testing.T) {

c.Equal(http.StatusCreated, status)
c.NotEmpty(submissionResponse["uuid"])

// ## Get submission status
submissionUUID := submissionResponse["uuid"].(string)

response := GetRealTimeSubmissionStatus(testBlockUUID, cookie)
c.Nil(response.err)

// Receive events
EXPECTED_EVENTS_COUNT := 3
receivedEventsCount := 0
receivedEvents := make(
[]*submissionsDTOs.SubmissionStatusUpdateDTO,
EXPECTED_EVENTS_COUNT,
)

scanner := bufio.NewScanner(response.w.Body)
for scanner.Scan() {
// Read the rew text
response := scanner.Text()

// Check if it is a data event
isData := strings.HasPrefix(response, "data:")
if !isData {
continue
}

// Remove the prefix
response = strings.TrimPrefix(response, "data:")
response = strings.TrimSpace(response)

var event submissionsDTOs.SubmissionStatusUpdateDTO
err := json.Unmarshal([]byte(response), &event)
c.Nil(err)

// Add the event to the list
receivedEvents[receivedEventsCount] = &event

receivedEventsCount++
if receivedEventsCount == EXPECTED_EVENTS_COUNT {
break
}
}

c.Nil(scanner.Err())

// Check the events
partialEventsStatus := []string{
"pending",
"running",
}

c.Equal(EXPECTED_EVENTS_COUNT, receivedEventsCount)
for idx, event := range receivedEvents {
c.Equal(submissionUUID, event.SubmissionUUID)

if idx < 2 {
c.Equal(partialEventsStatus[idx], event.SubmissionStatus)
c.False(event.TestsPassed)
c.Empty(event.TestsOutput)
} else {
c.Equal("ready", event.SubmissionStatus)
c.True(event.TestsPassed)
c.NotEmpty(event.TestsOutput)
}
}
}
40 changes: 40 additions & 0 deletions __tests__/integration/submissions_utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,43 @@ func SubmitSolutionToTestBlock(dto *SubmitSToTestBlockUtilsDTO) (response map[st
jsonResponse := ParseJsonResponse(w.Body)
return jsonResponse, w.Code
}

type closeNotifierRecorder struct {
*httptest.ResponseRecorder
closeNotify chan bool
}

func (c *closeNotifierRecorder) CloseNotify() <-chan bool {
return c.closeNotify
}

type RealTimeSubmissionStatusResponse struct {
w *closeNotifierRecorder
r *http.Request
err error
}

// GetRealTimeSubmissionStatus sends a request to the server to get the real time status of a submission and returns the response recorder
func GetRealTimeSubmissionStatus(testBlockUUID string, cookie *http.Cookie) *RealTimeSubmissionStatusResponse {
// Create the request
endpoint := fmt.Sprintf("/api/v1/submissions/%s/status", testBlockUUID)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return &RealTimeSubmissionStatusResponse{err: err}
}

req.AddCookie(cookie)

// Send the request
w := &closeNotifierRecorder{
ResponseRecorder: httptest.NewRecorder(),
closeNotify: make(chan bool, 1),
}
router.ServeHTTP(w, req)

return &RealTimeSubmissionStatusResponse{
w: w,
r: req,
err: err,
}
}
2 changes: 0 additions & 2 deletions sql/migrations/20230920232901_init.down.sql
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ DROP VIEW IF EXISTS objectives_owners;
DROP VIEW IF EXISTS criteria_owners;

-- ## Tables
DROP TABLE IF EXISTS files_deletion_error_logs;

DROP TABLE IF EXISTS archives;

DROP TABLE IF EXISTS grade_has_criteria;
Expand Down
8 changes: 0 additions & 8 deletions sql/migrations/20230920232901_init.up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,6 @@ CREATE TABLE IF NOT EXISTS archives (
"file_id" UUID NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS files_deletion_error_logs (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"file_id" UUID NOT NULL REFERENCES archives(id),
"file_type" VARCHAR(16) NOT NULL,
"requested_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"error_message" TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS languages (
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
"template_archive_id" UUID NOT NULL UNIQUE REFERENCES archives(id),
Expand Down
3 changes: 3 additions & 0 deletions src/blocks/domain/definitions/blocks_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,7 @@ type BlockRepository interface {
// Delete blocks
DeleteMarkdownBlock(blockUUID string) (err error)
DeleteTestBlock(blockUUID string) (err error)

// Get the laboratory the block belongs to
GetTestBlockLaboratoryUUID(blockUUID string) (laboratoryUUID string, err error)
}
43 changes: 21 additions & 22 deletions src/blocks/infrastructure/implementations/blocks_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,24 @@ func (repository *BlocksPostgresRepository) UpdateTestBlock(dto *dtos.UpdateTest
return nil
}

func (repository *BlocksPostgresRepository) GetTestBlockLaboratoryUUID(blockUUID string) (laboratoryUUID string, err error) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

query := `
SELECT laboratory_id
FROM test_blocks
WHERE id = $1
`

row := repository.Connection.QueryRowContext(ctx, query, blockUUID)
if err := row.Scan(&laboratoryUUID); err != nil {
return "", err
}

return laboratoryUUID, nil
}

func (repository *BlocksPostgresRepository) DeleteMarkdownBlock(blockUUID string) (err error) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
Expand Down Expand Up @@ -479,14 +497,14 @@ func (repository *BlocksPostgresRepository) deleteDependentArchives(archives []*
err := json.NewEncoder(&body).Encode(archive)
if err != nil {
errMessage := fmt.Sprintf("[ERR] - [BlocksPostgresRepository] - [deleteDependentArchives]: Unable to encode the request: %s", err.Error())
repository.saveDeletionErrorLog(archive, errMessage)
log.Println(errMessage)
}

// Create the request
req, err := http.NewRequest("POST", staticFilesEndpoint, &body)
if err != nil {
errMessage := fmt.Sprintf("[ERR] - [BlocksPostgresRepository] - [deleteDependentArchives]: Unable to create the request: %s", err.Error())
repository.saveDeletionErrorLog(archive, errMessage)
log.Println(errMessage)
}

// Send the request
Expand All @@ -497,26 +515,7 @@ func (repository *BlocksPostgresRepository) deleteDependentArchives(archives []*
microserviceError := sharedInfrastructure.ParseMicroserviceError(res, err)
if microserviceError != nil {
errMessage := fmt.Sprintf("[ERR] - [BlocksPostgresRepository] - [deleteDependentArchives]: Microservice returned an error: %s", microserviceError.Error())
repository.saveDeletionErrorLog(archive, errMessage)
log.Println(errMessage)
}
}
}

func (repository *BlocksPostgresRepository) saveDeletionErrorLog(archive *sharedEntities.StaticFileArchive, errorMessage string) {
// Log the error to the console
log.Println(errorMessage)

// Save the error log to the database
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

query := `
INSERT INTO files_deletion_error_logs (file_id, file_type, error_message)
VALUES ($1, $2, $3)
`

_, err := repository.Connection.ExecContext(ctx, query, archive.ArchiveUUID, archive.ArchiveType, errorMessage)
if err != nil {
log.Println("[ERR] - [BlocksPostgresRepository] - [saveDeletionErrorLog]: Unable to save the error log", err)
}
}
55 changes: 55 additions & 0 deletions src/submissions/application/use_cases.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ package application

import (
"mime/multipart"
"time"

blocksDefinitions "github.com/UPB-Code-Labs/main-api/src/blocks/domain/definitions"
laboratoriesDefinitions "github.com/UPB-Code-Labs/main-api/src/laboratories/domain/definitions"
"github.com/UPB-Code-Labs/main-api/src/submissions/domain/definitions"
"github.com/UPB-Code-Labs/main-api/src/submissions/domain/dtos"
"github.com/UPB-Code-Labs/main-api/src/submissions/domain/entities"
"github.com/UPB-Code-Labs/main-api/src/submissions/domain/errors"
)

type SubmissionUseCases struct {
LaboratoriesRepository laboratoriesDefinitions.LaboratoriesRepository
BlocksRepository blocksDefinitions.BlockRepository
SubmissionsRepository definitions.SubmissionsRepository
SubmissionsQueueManager definitions.SubmissionsQueueManager
Expand All @@ -31,13 +34,39 @@ func (useCases *SubmissionUseCases) SaveSubmission(dto *dtos.CreateSubmissionDTO
return "", errors.StudentCannotSubmitToTestBlock{}
}

// Validate the laboratory is open
isLaboratoryOpen, err := useCases.isTestBlockLaboratoryOpen(dto.TestBlockUUID)
if err != nil {
return "", err
}

if !isLaboratoryOpen {
return "", errors.LaboratoryIsClosed{}
}

// Check if the student already has a submission for the given test block
previousStudentSubmission, err := useCases.SubmissionsRepository.GetStudentSubmission(dto.StudentUUID, dto.TestBlockUUID)
if err != nil {
return "", err
}

if previousStudentSubmission != nil {
// Check if the previous submission was submitted in the last minute
parsedSubmittedAt, err := time.Parse(time.RFC3339, previousStudentSubmission.SubmittedAt)
if err != nil {
return "", err
}

if time.Since(parsedSubmittedAt).Minutes() < 1 {
return "", errors.StudentHasRecentSubmission{}
}

// Check if the previous submission is still pending
finalStatus := "ready"
if previousStudentSubmission.Status != finalStatus {
return "", errors.StudentHasPendingSubmission{}
}

// If the student already has a submission, reset its status and overwrite the archive
err = useCases.resetSubmissionStatus(previousStudentSubmission, dto.SubmissionArchive)
if err != nil {
Expand Down Expand Up @@ -68,6 +97,32 @@ func (useCases *SubmissionUseCases) SaveSubmission(dto *dtos.CreateSubmissionDTO
}
}

func (useCases *SubmissionUseCases) isTestBlockLaboratoryOpen(testBlockUUID string) (bool, error) {
// Get the UUID of the laboratory the test block belongs to
laboratoryUUID, err := useCases.BlocksRepository.GetTestBlockLaboratoryUUID(testBlockUUID)
if err != nil {
return false, err
}

// Get the laboratory
laboratory, err := useCases.LaboratoriesRepository.GetLaboratoryByUUID(laboratoryUUID)
if err != nil {
return false, err
}

// Check if the laboratory is open
parsedClosingDate, err := time.Parse(time.RFC3339, laboratory.DueDate)
if err != nil {
return false, err
}

if time.Now().After(parsedClosingDate) {
return false, nil
}

return true, nil
}

func (useCases *SubmissionUseCases) resetSubmissionStatus(previousStudentSubmission *entities.Submission, newArchive *multipart.File) error {
// Get the UUID of the .zip archive in the static files microservice
archiveUUID, err := useCases.SubmissionsRepository.GetStudentSubmissionArchiveUUIDFromSubmissionUUID(previousStudentSubmission.UUID)
Expand Down
1 change: 1 addition & 0 deletions src/submissions/domain/entities/submissions_entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type Submission struct {
Passing bool `json:"passing"`
Status string `json:"status"`
Stdout string `json:"stdout"`
SubmittedAt string `json:"-"`
}

type SubmissionWork struct {
Expand Down
Loading

0 comments on commit b2255a1

Please sign in to comment.