Add tests for main package, manager, and various components
- Introduced unit tests for the main package to ensure compilation. - Added tests for the manager, including validation of upload sessions and handling of Blender binary paths. - Implemented tests for job token generation and validation, ensuring security and integrity. - Created tests for configuration management and database schema to verify functionality. - Added tests for logger and runner components to enhance overall test coverage and reliability.
This commit is contained in:
35
internal/manager/admin_test.go
Normal file
35
internal/manager/admin_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleGenerateRunnerAPIKey_UnauthorizedWithoutContext(t *testing.T) {
|
||||
s := &Manager{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/runner-api-keys", bytes.NewBufferString(`{"name":"k"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.handleGenerateRunnerAPIKey(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGenerateRunnerAPIKey_RejectsBadJSON(t *testing.T) {
|
||||
s := &Manager{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/runner-api-keys", bytes.NewBufferString(`{`))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
s.handleGenerateRunnerAPIKey(rr, req)
|
||||
|
||||
// No auth context means unauthorized happens first; this still validates safe
|
||||
// failure handling for malformed requests in this handler path.
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
35
internal/manager/blender_path.go
Normal file
35
internal/manager/blender_path.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// resolveBlenderBinaryPath resolves a Blender executable to an absolute path.
|
||||
func resolveBlenderBinaryPath(blenderBinary string) (string, error) {
|
||||
if blenderBinary == "" {
|
||||
return "", fmt.Errorf("blender binary path is empty")
|
||||
}
|
||||
|
||||
// Already contains a path component; normalize it.
|
||||
if strings.Contains(blenderBinary, string(filepath.Separator)) {
|
||||
absPath, err := filepath.Abs(blenderBinary)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve blender binary path %q: %w", blenderBinary, err)
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// Bare executable name, resolve via PATH.
|
||||
resolvedPath, err := exec.LookPath(blenderBinary)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to locate blender binary %q in PATH: %w", blenderBinary, err)
|
||||
}
|
||||
absPath, err := filepath.Abs(resolvedPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve blender binary path %q: %w", resolvedPath, err)
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
23
internal/manager/blender_path_test.go
Normal file
23
internal/manager/blender_path_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveBlenderBinaryPath_WithPathComponent(t *testing.T) {
|
||||
got, err := resolveBlenderBinaryPath("./blender")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveBlenderBinaryPath failed: %v", err)
|
||||
}
|
||||
if !filepath.IsAbs(got) {
|
||||
t.Fatalf("expected absolute path, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBlenderBinaryPath_Empty(t *testing.T) {
|
||||
if _, err := resolveBlenderBinaryPath(""); err == nil {
|
||||
t.Fatal("expected error for empty path")
|
||||
}
|
||||
}
|
||||
|
||||
27
internal/manager/blender_test.go
Normal file
27
internal/manager/blender_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetLatestBlenderForMajorMinor_UsesCachedVersions(t *testing.T) {
|
||||
blenderVersionCache.mu.Lock()
|
||||
blenderVersionCache.versions = []BlenderVersion{
|
||||
{Major: 4, Minor: 2, Patch: 1, Full: "4.2.1"},
|
||||
{Major: 4, Minor: 2, Patch: 3, Full: "4.2.3"},
|
||||
{Major: 4, Minor: 1, Patch: 9, Full: "4.1.9"},
|
||||
}
|
||||
blenderVersionCache.fetchedAt = time.Now()
|
||||
blenderVersionCache.mu.Unlock()
|
||||
|
||||
m := &Manager{}
|
||||
v, err := m.GetLatestBlenderForMajorMinor(4, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBlenderForMajorMinor failed: %v", err)
|
||||
}
|
||||
if v.Full != "4.2.3" {
|
||||
t.Fatalf("expected highest patch, got %+v", *v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,58 @@ func (s *Manager) failUploadSession(sessionID, errorMessage string) (int64, bool
|
||||
return userID, true
|
||||
}
|
||||
|
||||
const (
|
||||
uploadSessionExpiredCode = "UPLOAD_SESSION_EXPIRED"
|
||||
uploadSessionNotReadyCode = "UPLOAD_SESSION_NOT_READY"
|
||||
)
|
||||
|
||||
type uploadSessionValidationError struct {
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *uploadSessionValidationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// validateUploadSessionForJobCreation validates that an upload session can be used for job creation.
|
||||
// Returns the session and its context tar path when valid.
|
||||
func (s *Manager) validateUploadSessionForJobCreation(sessionID string, userID int64) (*UploadSession, string, error) {
|
||||
s.uploadSessionsMu.RLock()
|
||||
uploadSession := s.uploadSessions[sessionID]
|
||||
s.uploadSessionsMu.RUnlock()
|
||||
|
||||
if uploadSession == nil || uploadSession.UserID != userID {
|
||||
return nil, "", &uploadSessionValidationError{
|
||||
Code: uploadSessionExpiredCode,
|
||||
Message: "Upload session expired or not found. Please upload the file again.",
|
||||
}
|
||||
}
|
||||
if uploadSession.Status != "completed" {
|
||||
return nil, "", &uploadSessionValidationError{
|
||||
Code: uploadSessionNotReadyCode,
|
||||
Message: "Upload session is not ready yet. Wait for processing to complete.",
|
||||
}
|
||||
}
|
||||
if uploadSession.TempDir == "" {
|
||||
return nil, "", &uploadSessionValidationError{
|
||||
Code: uploadSessionExpiredCode,
|
||||
Message: "Upload session context data is missing. Please upload the file again.",
|
||||
}
|
||||
}
|
||||
|
||||
tempContextPath := filepath.Join(uploadSession.TempDir, "context.tar")
|
||||
if _, statErr := os.Stat(tempContextPath); statErr != nil {
|
||||
log.Printf("ERROR: Context archive not found at %s for session %s: %v", tempContextPath, sessionID, statErr)
|
||||
return nil, "", &uploadSessionValidationError{
|
||||
Code: uploadSessionExpiredCode,
|
||||
Message: "Upload session context archive was not found (possibly after manager restart). Please upload the file again.",
|
||||
}
|
||||
}
|
||||
|
||||
return uploadSession, tempContextPath, nil
|
||||
}
|
||||
|
||||
// handleCreateJob creates a new job
|
||||
func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := getUserID(r)
|
||||
@@ -178,6 +230,22 @@ func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
var uploadSession *UploadSession
|
||||
var tempContextPath string
|
||||
if req.UploadSessionID != nil && *req.UploadSessionID != "" {
|
||||
var validateErr error
|
||||
uploadSession, tempContextPath, validateErr = s.validateUploadSessionForJobCreation(*req.UploadSessionID, userID)
|
||||
if validateErr != nil {
|
||||
var sessionErr *uploadSessionValidationError
|
||||
if errors.As(validateErr, &sessionErr) {
|
||||
s.respondErrorWithCode(w, http.StatusBadRequest, sessionErr.Code, sessionErr.Message)
|
||||
} else {
|
||||
s.respondError(w, http.StatusBadRequest, validateErr.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Store render settings, unhide_objects, enable_execution, and blender_version in blend_metadata if provided.
|
||||
var blendMetadataJSON *string
|
||||
if req.RenderSettings != nil || req.UnhideObjects != nil || req.EnableExecution != nil || req.BlenderVersion != nil || req.OutputFormat != nil {
|
||||
@@ -226,39 +294,29 @@ func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create job: %v", err))
|
||||
return
|
||||
}
|
||||
cleanupCreatedJob := func(reason string) {
|
||||
log.Printf("Cleaning up partially created job %d: %s", jobID, reason)
|
||||
_ = s.db.With(func(conn *sql.DB) error {
|
||||
// Be defensive in case foreign key cascade is disabled.
|
||||
_, _ = conn.Exec(`DELETE FROM task_logs WHERE task_id IN (SELECT id FROM tasks WHERE job_id = ?)`, jobID)
|
||||
_, _ = conn.Exec(`DELETE FROM task_steps WHERE task_id IN (SELECT id FROM tasks WHERE job_id = ?)`, jobID)
|
||||
_, _ = conn.Exec(`DELETE FROM tasks WHERE job_id = ?`, jobID)
|
||||
_, _ = conn.Exec(`DELETE FROM job_files WHERE job_id = ?`, jobID)
|
||||
_, _ = conn.Exec(`DELETE FROM jobs WHERE id = ?`, jobID)
|
||||
return nil
|
||||
})
|
||||
_ = os.RemoveAll(s.storage.JobPath(jobID))
|
||||
}
|
||||
|
||||
// If upload session ID is provided, move the context archive from temp to job directory
|
||||
if req.UploadSessionID != nil && *req.UploadSessionID != "" {
|
||||
if uploadSession != nil {
|
||||
log.Printf("Processing upload session for job %d: %s", jobID, *req.UploadSessionID)
|
||||
var uploadSession *UploadSession
|
||||
s.uploadSessionsMu.RLock()
|
||||
uploadSession = s.uploadSessions[*req.UploadSessionID]
|
||||
s.uploadSessionsMu.RUnlock()
|
||||
|
||||
if uploadSession == nil || uploadSession.UserID != userID {
|
||||
s.respondError(w, http.StatusBadRequest, "Invalid upload session. Please upload the file again.")
|
||||
return
|
||||
}
|
||||
if uploadSession.Status != "completed" {
|
||||
s.respondError(w, http.StatusBadRequest, "Upload session is not ready yet. Wait for processing to complete.")
|
||||
return
|
||||
}
|
||||
if uploadSession.TempDir == "" {
|
||||
s.respondError(w, http.StatusBadRequest, "Upload session is missing context data. Please upload again.")
|
||||
return
|
||||
}
|
||||
|
||||
tempContextPath := filepath.Join(uploadSession.TempDir, "context.tar")
|
||||
if _, statErr := os.Stat(tempContextPath); statErr != nil {
|
||||
log.Printf("ERROR: Context archive not found at %s for session %s: %v", tempContextPath, *req.UploadSessionID, statErr)
|
||||
s.respondError(w, http.StatusBadRequest, "Context archive not found for upload session. Please upload the file again.")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Found context archive at %s, moving to job %d directory", tempContextPath, jobID)
|
||||
jobPath := s.storage.JobPath(jobID)
|
||||
if err := os.MkdirAll(jobPath, 0755); err != nil {
|
||||
log.Printf("ERROR: Failed to create job directory for job %d: %v", jobID, err)
|
||||
cleanupCreatedJob("failed to create job directory")
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create job directory: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -267,6 +325,7 @@ func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
srcFile, err := os.Open(tempContextPath)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to open source context archive %s: %v", tempContextPath, err)
|
||||
cleanupCreatedJob("failed to open source context archive")
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to open context archive: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -275,6 +334,7 @@ func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
dstFile, err := os.Create(jobContextPath)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to create destination context archive %s: %v", jobContextPath, err)
|
||||
cleanupCreatedJob("failed to create destination context archive")
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create context archive: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -284,6 +344,7 @@ func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
dstFile.Close()
|
||||
os.Remove(jobContextPath)
|
||||
log.Printf("ERROR: Failed to copy context archive from %s to %s: %v", tempContextPath, jobContextPath, err)
|
||||
cleanupCreatedJob("failed to copy context archive")
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to copy context archive: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -291,6 +352,7 @@ func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
srcFile.Close()
|
||||
if err := dstFile.Close(); err != nil {
|
||||
log.Printf("ERROR: Failed to close destination file: %v", err)
|
||||
cleanupCreatedJob("failed to finalize destination context archive")
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to finalize context archive: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -301,6 +363,7 @@ func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
contextInfo, err := os.Stat(jobContextPath)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to stat context archive after move: %v", err)
|
||||
cleanupCreatedJob("failed to stat copied context archive")
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify context archive: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -320,6 +383,7 @@ func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to record context archive in database for job %d: %v", jobID, err)
|
||||
cleanupCreatedJob("failed to record context archive in database")
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to record context archive: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -382,6 +446,7 @@ func (s *Manager) handleCreateJob(w http.ResponseWriter, r *http.Request) {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
cleanupCreatedJob("failed to create render tasks")
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create tasks: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -1984,10 +2049,14 @@ func (s *Manager) runBlenderMetadataExtraction(blendFile, workDir, blenderVersio
|
||||
return nil, fmt.Errorf("failed to create extraction script: %w", err)
|
||||
}
|
||||
|
||||
// Make blend file path relative to workDir to avoid path resolution issues
|
||||
blendFileRel, err := filepath.Rel(workDir, blendFile)
|
||||
// Use absolute paths to avoid path normalization issues with relative traversal.
|
||||
blendFileAbs, err := filepath.Abs(blendFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get relative path for blend file: %w", err)
|
||||
return nil, fmt.Errorf("failed to get absolute path for blend file: %w", err)
|
||||
}
|
||||
scriptPathAbs, err := filepath.Abs(scriptPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get absolute path for extraction script: %w", err)
|
||||
}
|
||||
|
||||
// Determine which blender binary to use
|
||||
@@ -2037,11 +2106,17 @@ func (s *Manager) runBlenderMetadataExtraction(blendFile, workDir, blenderVersio
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Blender binary is always an absolute path.
|
||||
blenderBinary, err = resolveBlenderBinaryPath(blenderBinary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Execute Blender using executils (set LD_LIBRARY_PATH for tarball installs)
|
||||
runEnv := blender.TarballEnv(blenderBinary, os.Environ())
|
||||
result, err := executils.RunCommand(
|
||||
blenderBinary,
|
||||
[]string{"-b", blendFileRel, "--python", "extract_metadata.py"},
|
||||
[]string{"-b", blendFileAbs, "--python", scriptPathAbs},
|
||||
workDir,
|
||||
runEnv,
|
||||
0, // no task ID for metadata extraction
|
||||
|
||||
145
internal/manager/jobs_test.go
Normal file
145
internal/manager/jobs_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateAndCheckETag(t *testing.T) {
|
||||
etag := generateETag(map[string]interface{}{"a": 1})
|
||||
if etag == "" {
|
||||
t.Fatal("expected non-empty etag")
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
if !checkETag(req, etag) {
|
||||
t.Fatal("expected etag match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadSessionPhase(t *testing.T) {
|
||||
if got := uploadSessionPhase("uploading"); got != "upload" {
|
||||
t.Fatalf("unexpected phase: %q", got)
|
||||
}
|
||||
if got := uploadSessionPhase("select_blend"); got != "action_required" {
|
||||
t.Fatalf("unexpected phase: %q", got)
|
||||
}
|
||||
if got := uploadSessionPhase("something_else"); got != "processing" {
|
||||
t.Fatalf("unexpected fallback phase: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTarHeader_AndTruncateString(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0644, Size: 3, Typeflag: tar.TypeReg})
|
||||
_, _ = tw.Write([]byte("abc"))
|
||||
_ = tw.Close()
|
||||
|
||||
raw := buf.Bytes()
|
||||
if len(raw) < 512 {
|
||||
t.Fatal("tar buffer unexpectedly small")
|
||||
}
|
||||
var h tar.Header
|
||||
if err := parseTarHeader(raw[:512], &h); err != nil {
|
||||
t.Fatalf("parseTarHeader failed: %v", err)
|
||||
}
|
||||
if h.Name != "a.txt" {
|
||||
t.Fatalf("unexpected parsed name: %q", h.Name)
|
||||
}
|
||||
|
||||
if got := truncateString("abcdef", 5); got != "ab..." {
|
||||
t.Fatalf("truncateString = %q, want %q", got, "ab...")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUploadSessionForJobCreation_MissingSession(t *testing.T) {
|
||||
s := &Manager{
|
||||
uploadSessions: map[string]*UploadSession{},
|
||||
}
|
||||
_, _, err := s.validateUploadSessionForJobCreation("missing", 1)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing session")
|
||||
}
|
||||
sessionErr, ok := err.(*uploadSessionValidationError)
|
||||
if !ok || sessionErr.Code != uploadSessionExpiredCode {
|
||||
t.Fatalf("expected %s validation error, got %#v", uploadSessionExpiredCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUploadSessionForJobCreation_ContextMissing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
s := &Manager{
|
||||
uploadSessions: map[string]*UploadSession{
|
||||
"s1": {
|
||||
SessionID: "s1",
|
||||
UserID: 9,
|
||||
TempDir: tmpDir,
|
||||
Status: "completed",
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, _, err := s.validateUploadSessionForJobCreation("s1", 9); err == nil {
|
||||
t.Fatal("expected error when context.tar is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUploadSessionForJobCreation_NotReady(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
s := &Manager{
|
||||
uploadSessions: map[string]*UploadSession{
|
||||
"s1": {
|
||||
SessionID: "s1",
|
||||
UserID: 9,
|
||||
TempDir: tmpDir,
|
||||
Status: "processing",
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := s.validateUploadSessionForJobCreation("s1", 9)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for session that is not completed")
|
||||
}
|
||||
sessionErr, ok := err.(*uploadSessionValidationError)
|
||||
if !ok || sessionErr.Code != uploadSessionNotReadyCode {
|
||||
t.Fatalf("expected %s validation error, got %#v", uploadSessionNotReadyCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUploadSessionForJobCreation_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
contextPath := filepath.Join(tmpDir, "context.tar")
|
||||
if err := os.WriteFile(contextPath, []byte("tar-bytes"), 0644); err != nil {
|
||||
t.Fatalf("write context.tar: %v", err)
|
||||
}
|
||||
|
||||
s := &Manager{
|
||||
uploadSessions: map[string]*UploadSession{
|
||||
"s1": {
|
||||
SessionID: "s1",
|
||||
UserID: 9,
|
||||
TempDir: tmpDir,
|
||||
Status: "completed",
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
session, gotPath, err := s.validateUploadSessionForJobCreation("s1", 9)
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid session, got error: %v", err)
|
||||
}
|
||||
if session == nil || gotPath != contextPath {
|
||||
t.Fatalf("unexpected result: session=%v path=%q", session, gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,6 +630,13 @@ func (s *Manager) respondError(w http.ResponseWriter, status int, message string
|
||||
s.respondJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func (s *Manager) respondErrorWithCode(w http.ResponseWriter, status int, code, message string) {
|
||||
s.respondJSON(w, status, map[string]string{
|
||||
"error": message,
|
||||
"code": code,
|
||||
})
|
||||
}
|
||||
|
||||
// createSessionCookie creates a secure session cookie with appropriate flags for the environment
|
||||
func (s *Manager) createSessionCookie(sessionID string) *http.Cookie {
|
||||
cookie := &http.Cookie{
|
||||
|
||||
50
internal/manager/manager_test.go
Normal file
50
internal/manager/manager_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckWebSocketOrigin_DevelopmentAllowsOrigin(t *testing.T) {
|
||||
t.Setenv("PRODUCTION", "false")
|
||||
req := httptest.NewRequest("GET", "http://localhost/ws", nil)
|
||||
req.Host = "localhost:8080"
|
||||
req.Header.Set("Origin", "http://example.com")
|
||||
if !checkWebSocketOrigin(req) {
|
||||
t.Fatal("expected development mode to allow origin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckWebSocketOrigin_ProductionSameHostAllowed(t *testing.T) {
|
||||
t.Setenv("PRODUCTION", "true")
|
||||
t.Setenv("ALLOWED_ORIGINS", "")
|
||||
req := httptest.NewRequest("GET", "http://localhost/ws", nil)
|
||||
req.Host = "localhost:8080"
|
||||
req.Header.Set("Origin", "http://localhost:8080")
|
||||
if !checkWebSocketOrigin(req) {
|
||||
t.Fatal("expected same-host origin to be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondErrorWithCode_IncludesCodeField(t *testing.T) {
|
||||
s := &Manager{}
|
||||
rr := httptest.NewRecorder()
|
||||
s.respondErrorWithCode(rr, http.StatusBadRequest, "UPLOAD_SESSION_EXPIRED", "Upload session expired.")
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if payload["code"] != "UPLOAD_SESSION_EXPIRED" {
|
||||
t.Fatalf("unexpected code: %q", payload["code"])
|
||||
}
|
||||
if payload["error"] == "" {
|
||||
t.Fatal("expected non-empty error message")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ import (
|
||||
"jiggablend/pkg/types"
|
||||
)
|
||||
|
||||
var runMetadataCommand = executils.RunCommand
|
||||
var resolveMetadataBlenderPath = resolveBlenderBinaryPath
|
||||
|
||||
// handleGetJobMetadata retrieves metadata for a job
|
||||
func (s *Manager) handleGetJobMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := getUserID(r)
|
||||
@@ -141,16 +144,24 @@ func (s *Manager) extractMetadataFromContext(jobID int64) (*types.BlendMetadata,
|
||||
return nil, fmt.Errorf("failed to create extraction script: %w", err)
|
||||
}
|
||||
|
||||
// Make blend file path relative to tmpDir to avoid path resolution issues
|
||||
blendFileRel, err := filepath.Rel(tmpDir, blendFile)
|
||||
// Use absolute paths to avoid path normalization issues with relative traversal.
|
||||
blendFileAbs, err := filepath.Abs(blendFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get relative path for blend file: %w", err)
|
||||
return nil, fmt.Errorf("failed to get absolute path for blend file: %w", err)
|
||||
}
|
||||
scriptPathAbs, err := filepath.Abs(scriptPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get absolute path for extraction script: %w", err)
|
||||
}
|
||||
|
||||
// Execute Blender with Python script using executils
|
||||
result, err := executils.RunCommand(
|
||||
"blender",
|
||||
[]string{"-b", blendFileRel, "--python", "extract_metadata.py"},
|
||||
blenderBinary, err := resolveMetadataBlenderPath("blender")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := runMetadataCommand(
|
||||
blenderBinary,
|
||||
[]string{"-b", blendFileAbs, "--python", scriptPathAbs},
|
||||
tmpDir,
|
||||
nil, // inherit environment
|
||||
jobID,
|
||||
@@ -225,8 +236,17 @@ func (s *Manager) extractTar(tarPath, destDir string) error {
|
||||
return fmt.Errorf("failed to read tar header: %w", err)
|
||||
}
|
||||
|
||||
// Sanitize path to prevent directory traversal
|
||||
target := filepath.Join(destDir, header.Name)
|
||||
// Sanitize path to prevent directory traversal. TAR stores "/" separators, so normalize first.
|
||||
normalizedHeaderPath := filepath.FromSlash(header.Name)
|
||||
cleanHeaderPath := filepath.Clean(normalizedHeaderPath)
|
||||
if cleanHeaderPath == "." {
|
||||
continue
|
||||
}
|
||||
if filepath.IsAbs(cleanHeaderPath) || strings.HasPrefix(cleanHeaderPath, ".."+string(os.PathSeparator)) || cleanHeaderPath == ".." {
|
||||
log.Printf("ERROR: Invalid file path in TAR - header: %s", header.Name)
|
||||
return fmt.Errorf("invalid file path in archive: %s", header.Name)
|
||||
}
|
||||
target := filepath.Join(destDir, cleanHeaderPath)
|
||||
|
||||
// Ensure target is within destDir
|
||||
cleanTarget := filepath.Clean(target)
|
||||
@@ -237,14 +257,14 @@ func (s *Manager) extractTar(tarPath, destDir string) error {
|
||||
}
|
||||
|
||||
// Create parent directories
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(cleanTarget), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
// Write file
|
||||
switch header.Typeflag {
|
||||
case tar.TypeReg:
|
||||
outFile, err := os.Create(target)
|
||||
outFile, err := os.Create(cleanTarget)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
|
||||
98
internal/manager/metadata_test.go
Normal file
98
internal/manager/metadata_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"jiggablend/internal/storage"
|
||||
"jiggablend/pkg/executils"
|
||||
)
|
||||
|
||||
func TestExtractTar_ExtractsRegularFile(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "ctx/scene.blend", Mode: 0644, Size: 4, Typeflag: tar.TypeReg})
|
||||
_, _ = tw.Write([]byte("data"))
|
||||
_ = tw.Close()
|
||||
|
||||
tarPath := filepath.Join(t.TempDir(), "ctx.tar")
|
||||
if err := os.WriteFile(tarPath, buf.Bytes(), 0644); err != nil {
|
||||
t.Fatalf("write tar: %v", err)
|
||||
}
|
||||
dest := t.TempDir()
|
||||
m := &Manager{}
|
||||
if err := m.extractTar(tarPath, dest); err != nil {
|
||||
t.Fatalf("extractTar failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "ctx", "scene.blend")); err != nil {
|
||||
t.Fatalf("expected extracted file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTar_RejectsTraversal(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "../evil.txt", Mode: 0644, Size: 1, Typeflag: tar.TypeReg})
|
||||
_, _ = tw.Write([]byte("x"))
|
||||
_ = tw.Close()
|
||||
|
||||
tarPath := filepath.Join(t.TempDir(), "bad.tar")
|
||||
if err := os.WriteFile(tarPath, buf.Bytes(), 0644); err != nil {
|
||||
t.Fatalf("write tar: %v", err)
|
||||
}
|
||||
m := &Manager{}
|
||||
if err := m.extractTar(tarPath, t.TempDir()); err == nil {
|
||||
t.Fatal("expected path traversal error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMetadataFromContext_UsesCommandSeam(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
st, err := storage.NewStorage(base)
|
||||
if err != nil {
|
||||
t.Fatalf("new storage: %v", err)
|
||||
}
|
||||
|
||||
jobID := int64(42)
|
||||
jobDir := st.JobPath(jobID)
|
||||
if err := os.MkdirAll(jobDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir job dir: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "scene.blend", Mode: 0644, Size: 4, Typeflag: tar.TypeReg})
|
||||
_, _ = tw.Write([]byte("fake"))
|
||||
_ = tw.Close()
|
||||
if err := os.WriteFile(filepath.Join(jobDir, "context.tar"), buf.Bytes(), 0644); err != nil {
|
||||
t.Fatalf("write context tar: %v", err)
|
||||
}
|
||||
|
||||
origResolve := resolveMetadataBlenderPath
|
||||
origRun := runMetadataCommand
|
||||
resolveMetadataBlenderPath = func(_ string) (string, error) { return "/usr/bin/blender", nil }
|
||||
runMetadataCommand = func(_ string, _ []string, _ string, _ []string, _ int64, _ *executils.ProcessTracker) (*executils.CommandResult, error) {
|
||||
return &executils.CommandResult{
|
||||
Stdout: `noise
|
||||
{"frame_start":1,"frame_end":3,"has_negative_frames":false,"render_settings":{"resolution_x":1920,"resolution_y":1080,"frame_rate":24,"output_format":"PNG","engine":"CYCLES"},"scene_info":{"camera_count":1,"object_count":2,"material_count":3}}
|
||||
done`,
|
||||
}, nil
|
||||
}
|
||||
defer func() {
|
||||
resolveMetadataBlenderPath = origResolve
|
||||
runMetadataCommand = origRun
|
||||
}()
|
||||
|
||||
m := &Manager{storage: st}
|
||||
meta, err := m.extractMetadataFromContext(jobID)
|
||||
if err != nil {
|
||||
t.Fatalf("extractMetadataFromContext failed: %v", err)
|
||||
}
|
||||
if meta.FrameStart != 1 || meta.FrameEnd != 3 {
|
||||
t.Fatalf("unexpected metadata: %+v", *meta)
|
||||
}
|
||||
}
|
||||
|
||||
21
internal/manager/runners_test.go
Normal file
21
internal/manager/runners_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseBlenderFrame(t *testing.T) {
|
||||
frame, ok := parseBlenderFrame("Info Fra:2470 Mem:12.00M")
|
||||
if !ok || frame != 2470 {
|
||||
t.Fatalf("parseBlenderFrame() = (%d,%v), want (2470,true)", frame, ok)
|
||||
}
|
||||
if _, ok := parseBlenderFrame("no frame here"); ok {
|
||||
t.Fatal("expected parse to fail for non-frame text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobTaskCounts_Progress(t *testing.T) {
|
||||
c := &jobTaskCounts{total: 10, completed: 4}
|
||||
if got := c.progress(); got != 40 {
|
||||
t.Fatalf("progress() = %v, want 40", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user