Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions engine/db/injects.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ func GetInjects() ([]InjectSchema, error) {
return injects, nil
}

// GetInjectByID retrieves a single inject by ID
func GetInjectByID(id uint) (InjectSchema, error) {
var inject InjectSchema
result := db.Table("inject_schemas").First(&inject, id)
if result.Error != nil {
return InjectSchema{}, result.Error
}
return inject, nil
}

// UpdateInject
func UpdateInject(inject InjectSchema) (InjectSchema, error) {
result := db.Table("inject_schemas").Save(&inject)
Expand Down
3 changes: 3 additions & 0 deletions static/templates/pages/injects.html
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,9 @@ <h5 class="modal-title" id="delete__form--label">Delete Inject</h5>
data-bs-target="#delete__form">
Delete
</button>
<a id="download-all-submissions--placeholder" class="btn btn-outline-primary rounded-1 m-2" href="/api/injects/placeholder/submissions/download">
Download All Submissions
</a>
</div>
{{ end }}
<div class="h-100 p-4 overflow-auto">
Expand Down
194 changes: 194 additions & 0 deletions tests/integration/submissions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package integration

import (
"archive/zip"
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"quotient/engine/db"
"quotient/tests/testutil"
"quotient/www/api"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDownloadAllSubmissions(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}

pgContainer := testutil.StartPostgres(t)
defer pgContainer.Close()
db.Connect(pgContainer.ConnectionString())

// Use temp dir for submission files
submissionsDir := t.TempDir()
originalWd, _ := os.Getwd()
os.Chdir(submissionsDir)
defer os.Chdir(originalWd)

// Setup: team, inject, submissions
team, err := db.CreateTeam(db.TeamSchema{
Name: fmt.Sprintf("Team-%d", time.Now().UnixNano()),
Identifier: "01",
Active: true,
})
require.NoError(t, err, "failed to create team")
t.Logf("Created team ID: %d", team.ID)

inject, err := db.CreateInject(db.InjectSchema{
Title: fmt.Sprintf("Test Inject-%d", time.Now().UnixNano()),
OpenTime: time.Now().Add(-1 * time.Hour),
DueTime: time.Now().Add(1 * time.Hour),
CloseTime: time.Now().Add(2 * time.Hour),
})
require.NoError(t, err, "failed to create inject")
t.Logf("Created inject ID: %d", inject.ID)

// Create 2 submission versions
for v := 1; v <= 2; v++ {
dir := filepath.Join("submissions", fmt.Sprintf("%d/%d/%d", inject.ID, team.ID, v))
err := os.MkdirAll(dir, 0750)
require.NoError(t, err, "failed to create dir")

filename := fmt.Sprintf("report_v%d.pdf", v)
err = os.WriteFile(filepath.Join(dir, filename), []byte(fmt.Sprintf("content %d", v)), 0644)
require.NoError(t, err, "failed to write file")

sub, err := db.CreateSubmission(db.SubmissionSchema{
TeamID: team.ID,
InjectID: inject.ID,
SubmissionTime: time.Now(),
SubmissionFileName: filename,
})
require.NoError(t, err, "failed to create submission")
t.Logf("Created submission version %d for team %d", sub.Version, team.ID)
}

// Verify submissions exist
subs, err := db.GetSubmissionsForInject(inject.ID)
require.NoError(t, err)
t.Logf("Found %d submissions for inject %d", len(subs), inject.ID)

// Make request
req := httptest.NewRequest("GET", fmt.Sprintf("/api/injects/%d/submissions/download", inject.ID), nil)
req.SetPathValue("id", fmt.Sprintf("%d", inject.ID))
rr := httptest.NewRecorder()

api.DownloadAllSubmissions(rr, req)

// Verify ZIP response
if rr.Code != http.StatusOK {
t.Logf("Response body: %s", rr.Body.String())
}
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "application/zip", rr.Header().Get("Content-Type"))

zipReader, err := zip.NewReader(bytes.NewReader(rr.Body.Bytes()), int64(rr.Body.Len()))
require.NoError(t, err)
require.Len(t, zipReader.File, 2)

// Verify files are in ZIP with correct content
for _, f := range zipReader.File {
rc, _ := f.Open()
content, _ := io.ReadAll(rc)
rc.Close()
assert.Contains(t, string(content), "content")
}
}

func TestDownloadAllSubmissions_MultipleTeamsSameFilename(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}

pgContainer := testutil.StartPostgres(t)
defer pgContainer.Close()
db.Connect(pgContainer.ConnectionString())

// Use temp dir for submission files
submissionsDir := t.TempDir()
originalWd, _ := os.Getwd()
os.Chdir(submissionsDir)
defer os.Chdir(originalWd)

// Create inject
inject, err := db.CreateInject(db.InjectSchema{
Title: fmt.Sprintf("Multi-Team Inject-%d", time.Now().UnixNano()),
OpenTime: time.Now().Add(-1 * time.Hour),
DueTime: time.Now().Add(1 * time.Hour),
CloseTime: time.Now().Add(2 * time.Hour),
})
require.NoError(t, err)
t.Logf("Created inject ID: %d", inject.ID)

// Create 3 teams, each submitting a file with the same name
numTeams := 3
teams := make([]db.TeamSchema, numTeams)
for i := 0; i < numTeams; i++ {
team, err := db.CreateTeam(db.TeamSchema{
Name: fmt.Sprintf("Team%d-%d", i+1, time.Now().UnixNano()),
Identifier: fmt.Sprintf("%02d", i+1),
Active: true,
})
require.NoError(t, err)
teams[i] = team
t.Logf("Created team: %s (ID: %d)", team.Name, team.ID)

// Each team submits "report.pdf" with unique content
dir := filepath.Join("submissions", fmt.Sprintf("%d/%d/1", inject.ID, team.ID))
err = os.MkdirAll(dir, 0750)
require.NoError(t, err)

content := fmt.Sprintf("Report from team %d", team.ID)
err = os.WriteFile(filepath.Join(dir, "report.pdf"), []byte(content), 0644)
require.NoError(t, err)

_, err = db.CreateSubmission(db.SubmissionSchema{
TeamID: team.ID,
InjectID: inject.ID,
SubmissionTime: time.Now(),
SubmissionFileName: "report.pdf",
})
require.NoError(t, err)
}

// Make request
req := httptest.NewRequest("GET", fmt.Sprintf("/api/injects/%d/submissions/download", inject.ID), nil)
req.SetPathValue("id", fmt.Sprintf("%d", inject.ID))
rr := httptest.NewRecorder()

api.DownloadAllSubmissions(rr, req)

// Verify response
if rr.Code != http.StatusOK {
t.Logf("Response body: %s", rr.Body.String())
}
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "application/zip", rr.Header().Get("Content-Type"))

// Verify ZIP contains all 3 files (not overwritten)
zipReader, err := zip.NewReader(bytes.NewReader(rr.Body.Bytes()), int64(rr.Body.Len()))
require.NoError(t, err)
require.Len(t, zipReader.File, numTeams, "ZIP should contain one file per team")

// Verify each file has unique content from different teams
contents := make(map[string]bool)
for _, f := range zipReader.File {
t.Logf("ZIP entry: %s", f.Name)
rc, _ := f.Open()
content, _ := io.ReadAll(rc)
rc.Close()
contents[string(content)] = true
}

// All 3 team contents should be unique
assert.Len(t, contents, numTeams, "All team submissions should have unique content in ZIP")
}
65 changes: 65 additions & 0 deletions www/api/submissions.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package api

import (
"archive/zip"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"os"
"path/filepath"
"quotient/engine/db"
"slices"
"strconv"
Expand Down Expand Up @@ -173,3 +176,65 @@ func DownloadSubmissionFile(w http.ResponseWriter, r *http.Request) {
return
}
}

func DownloadAllSubmissions(w http.ResponseWriter, r *http.Request) {
temp, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid inject id"})
return
}
injectID := uint(temp)

if _, err := db.GetInjectByID(injectID); err != nil {
WriteJSON(w, http.StatusNotFound, map[string]any{"error": "Inject not found"})
return
}

submissions, err := db.GetSubmissionsForInject(injectID)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Error retrieving submissions"})
return
}

if len(submissions) == 0 {
WriteJSON(w, http.StatusNotFound, map[string]any{"error": "No submissions found"})
return
}

filename := fmt.Sprintf("inject%d_submissions.zip", injectID)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("Content-Type", "application/zip")

zipWriter := zip.NewWriter(w)
defer zipWriter.Close()

// SubmissionFileName is validated by os.Root at upload time; Team.Name is admin-controlled.
for _, submission := range submissions {
baseDir := fmt.Sprintf("submissions/%d/%d/%d", injectID, submission.TeamID, submission.Version)
file, err := SafeOpen(baseDir, submission.SubmissionFileName)
if err != nil {
continue
}

zipPath := filepath.Join(
fmt.Sprintf("%s_v%d", submission.Team.Name, submission.Version),
submission.SubmissionFileName,
)

zipEntry, err := zipWriter.Create(zipPath)
if err != nil {
slog.Error("failed to create zip entry", "path", zipPath, "error", err)
if err := file.Close(); err != nil {
slog.Error("failed to close file", "path", zipPath, "error", err)
}
continue
}

if _, err := io.Copy(zipEntry, file); err != nil {
slog.Error("failed to copy file to zip", "path", zipPath, "error", err)
}
if err := file.Close(); err != nil {
slog.Error("failed to close file", "path", zipPath, "error", err)
}
}
}
1 change: 1 addition & 0 deletions www/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ func (router *Router) Start() {
mux.HandleFunc("POST /api/injects/create", INJECTAUTH(api.CreateInject))
mux.HandleFunc("POST /api/injects/{id}", INJECTAUTH(api.UpdateInject))
mux.HandleFunc("DELETE /api/injects/{id}", INJECTAUTH(api.DeleteInject))
mux.HandleFunc("GET /api/injects/{id}/submissions/download", INJECTAUTH(api.DownloadAllSubmissions))

// router.HandleFunc("POST /api/engine/service/create", ADMINAUTH(api.CreateService))
// router.HandleFunc("POST /api/engine/service/update", ADMINAUTH(api.UpdateService))
Expand Down