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:
2026-03-14 22:20:03 -05:00
parent 16d6a95058
commit a3defe5cf6
45 changed files with 1717 additions and 52 deletions

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"os/exec"
"path/filepath"
"strings"
"jiggablend/internal/auth"
@@ -151,7 +152,15 @@ func runManager(cmd *cobra.Command, args []string) {
}
func checkBlenderAvailable() error {
cmd := exec.Command("blender", "--version")
blenderPath, err := exec.LookPath("blender")
if err != nil {
return fmt.Errorf("failed to locate blender in PATH: %w", err)
}
blenderPath, err = filepath.Abs(blenderPath)
if err != nil {
return fmt.Errorf("failed to resolve blender path %q: %w", blenderPath, err)
}
cmd := exec.Command(blenderPath, "--version")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to run 'blender --version': %w (output: %s)", err, string(output))

View File

@@ -0,0 +1,23 @@
package cmd
import (
"strings"
"testing"
)
func TestGenerateAPIKey_Format(t *testing.T) {
key, prefix, hash, err := generateAPIKey()
if err != nil {
t.Fatalf("generateAPIKey failed: %v", err)
}
if !strings.HasPrefix(prefix, "jk_r") {
t.Fatalf("unexpected prefix: %q", prefix)
}
if !strings.HasPrefix(key, prefix+"_") {
t.Fatalf("key does not include prefix: %q", key)
}
if len(hash) != 64 {
t.Fatalf("expected sha256 hex hash length, got %d", len(hash))
}
}

View File

@@ -0,0 +1,16 @@
package cmd
import "testing"
func TestRootCommand_HasKeySubcommands(t *testing.T) {
names := map[string]bool{}
for _, c := range rootCmd.Commands() {
names[c.Name()] = true
}
for _, required := range []string{"manager", "runner", "version"} {
if !names[required] {
t.Fatalf("expected subcommand %q to be registered", required)
}
}
}

View File

@@ -0,0 +1,17 @@
package cmd
import (
"encoding/hex"
"testing"
)
func TestGenerateShortID_IsHex8Bytes(t *testing.T) {
id := generateShortID()
if len(id) != 8 {
t.Fatalf("expected 8 hex chars, got %q", id)
}
if _, err := hex.DecodeString(id); err != nil {
t.Fatalf("id should be hex: %v", err)
}
}

View File

@@ -0,0 +1,13 @@
package cmd
import "testing"
func TestVersionCommand_Metadata(t *testing.T) {
if versionCmd.Use != "version" {
t.Fatalf("unexpected command use: %q", versionCmd.Use)
}
if versionCmd.Run == nil {
t.Fatal("version command run function should be set")
}
}

View File

@@ -0,0 +1,8 @@
package main
import "testing"
func TestMainPackage_Builds(t *testing.T) {
// Smoke test placeholder to keep package main under test compilation.
}

View File

@@ -0,0 +1,56 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestContextHelpers(t *testing.T) {
ctx := context.Background()
ctx = context.WithValue(ctx, contextKeyUserID, int64(123))
ctx = context.WithValue(ctx, contextKeyIsAdmin, true)
id, ok := GetUserID(ctx)
if !ok || id != 123 {
t.Fatalf("GetUserID() = (%d,%v), want (123,true)", id, ok)
}
if !IsAdmin(ctx) {
t.Fatal("expected IsAdmin to be true")
}
}
func TestIsProductionMode_UsesEnv(t *testing.T) {
t.Setenv("PRODUCTION", "true")
if !IsProductionMode() {
t.Fatal("expected production mode true when env is set")
}
}
func TestWriteUnauthorized_BehaviorByRequestType(t *testing.T) {
a := &Auth{}
reqAPI := httptest.NewRequest(http.MethodGet, "/api/jobs", nil)
rrAPI := httptest.NewRecorder()
a.writeUnauthorized(rrAPI, reqAPI)
if rrAPI.Code != http.StatusUnauthorized {
t.Fatalf("api code = %d", rrAPI.Code)
}
reqPage := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
rrPage := httptest.NewRecorder()
a.writeUnauthorized(rrPage, reqPage)
if rrPage.Code != http.StatusFound {
t.Fatalf("page code = %d", rrPage.Code)
}
}
func TestIsProductionMode_DefaultFalse(t *testing.T) {
_ = os.Unsetenv("PRODUCTION")
if IsProductionMode() {
t.Fatal("expected false when PRODUCTION is unset")
}
}

View File

@@ -0,0 +1,84 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
)
func TestGenerateAndValidateJobToken_RoundTrip(t *testing.T) {
token, err := GenerateJobToken(10, 20, 30)
if err != nil {
t.Fatalf("GenerateJobToken failed: %v", err)
}
claims, err := ValidateJobToken(token)
if err != nil {
t.Fatalf("ValidateJobToken failed: %v", err)
}
if claims.JobID != 10 || claims.RunnerID != 20 || claims.TaskID != 30 {
t.Fatalf("unexpected claims: %+v", claims)
}
}
func TestValidateJobToken_RejectsTampering(t *testing.T) {
token, err := GenerateJobToken(1, 2, 3)
if err != nil {
t.Fatalf("GenerateJobToken failed: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 2 {
t.Fatalf("unexpected token format: %q", token)
}
rawClaims, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("decode claims failed: %v", err)
}
var claims JobTokenClaims
if err := json.Unmarshal(rawClaims, &claims); err != nil {
t.Fatalf("unmarshal claims failed: %v", err)
}
claims.JobID = 999
tamperedClaims, _ := json.Marshal(claims)
tampered := base64.RawURLEncoding.EncodeToString(tamperedClaims) + "." + parts[1]
if _, err := ValidateJobToken(tampered); err == nil {
t.Fatal("expected signature validation error for tampered token")
}
}
func TestValidateJobToken_RejectsExpired(t *testing.T) {
expiredClaims := JobTokenClaims{
JobID: 1,
RunnerID: 2,
TaskID: 3,
Exp: time.Now().Add(-time.Minute).Unix(),
}
claimsJSON, _ := json.Marshal(expiredClaims)
sigToken, err := GenerateJobToken(1, 2, 3)
if err != nil {
t.Fatalf("GenerateJobToken failed: %v", err)
}
parts := strings.Split(sigToken, ".")
if len(parts) != 2 {
t.Fatalf("unexpected token format: %q", sigToken)
}
// Re-sign expired payload with package secret.
h := signClaimsForTest(claimsJSON)
expiredToken := base64.RawURLEncoding.EncodeToString(claimsJSON) + "." + base64.RawURLEncoding.EncodeToString(h)
if _, err := ValidateJobToken(expiredToken); err == nil {
t.Fatal("expected token expiration error")
}
}
func signClaimsForTest(claims []byte) []byte {
h := hmac.New(sha256.New, jobTokenSecret)
_, _ = h.Write(claims)
return h.Sum(nil)
}

View File

@@ -0,0 +1,32 @@
package auth
import (
"strings"
"testing"
)
func TestGenerateSecret_Length(t *testing.T) {
secret, err := generateSecret(8)
if err != nil {
t.Fatalf("generateSecret failed: %v", err)
}
// hex encoding doubles length
if len(secret) != 16 {
t.Fatalf("unexpected secret length: %d", len(secret))
}
}
func TestGenerateAPIKey_Format(t *testing.T) {
s := &Secrets{}
key, err := s.generateAPIKey()
if err != nil {
t.Fatalf("generateAPIKey failed: %v", err)
}
if !strings.HasPrefix(key, "jk_r") {
t.Fatalf("unexpected key prefix: %q", key)
}
if !strings.Contains(key, "_") {
t.Fatalf("unexpected key format: %q", key)
}
}

View File

@@ -0,0 +1,66 @@
package config
import (
"path/filepath"
"testing"
"jiggablend/internal/database"
)
func newTestConfig(t *testing.T) *Config {
t.Helper()
db, err := database.NewDB(filepath.Join(t.TempDir(), "cfg.db"))
if err != nil {
t.Fatalf("NewDB failed: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return NewConfig(db)
}
func TestSetGetExistsDelete(t *testing.T) {
cfg := newTestConfig(t)
if err := cfg.Set("alpha", "1"); err != nil {
t.Fatalf("Set failed: %v", err)
}
v, err := cfg.Get("alpha")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if v != "1" {
t.Fatalf("unexpected value: %q", v)
}
exists, err := cfg.Exists("alpha")
if err != nil {
t.Fatalf("Exists failed: %v", err)
}
if !exists {
t.Fatal("expected key to exist")
}
if err := cfg.Delete("alpha"); err != nil {
t.Fatalf("Delete failed: %v", err)
}
exists, err = cfg.Exists("alpha")
if err != nil {
t.Fatalf("Exists after delete failed: %v", err)
}
if exists {
t.Fatal("expected key to be deleted")
}
}
func TestGetIntWithDefault_AndMinimumFrameTask(t *testing.T) {
cfg := newTestConfig(t)
if got := cfg.GetIntWithDefault("missing", 17); got != 17 {
t.Fatalf("expected default value, got %d", got)
}
if err := cfg.SetInt(KeyFramesPerRenderTask, 0); err != nil {
t.Fatalf("SetInt failed: %v", err)
}
if got := cfg.GetFramesPerRenderTask(); got != 1 {
t.Fatalf("expected clamped value 1, got %d", got)
}
}

View File

@@ -0,0 +1,58 @@
package database
import (
"database/sql"
"path/filepath"
"testing"
)
func TestNewDB_RunsMigrationsAndSupportsQueries(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := NewDB(dbPath)
if err != nil {
t.Fatalf("NewDB failed: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
t.Fatalf("Ping failed: %v", err)
}
var exists bool
err = db.With(func(conn *sql.DB) error {
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings')").Scan(&exists)
})
if err != nil {
t.Fatalf("query failed: %v", err)
}
if !exists {
t.Fatal("expected settings table after migrations")
}
}
func TestWithTx_RollbackOnError(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "tx.db")
db, err := NewDB(dbPath)
if err != nil {
t.Fatalf("NewDB failed: %v", err)
}
defer db.Close()
_ = db.WithTx(func(tx *sql.Tx) error {
if _, err := tx.Exec("INSERT INTO settings (key, value) VALUES (?, ?)", "rollback_key", "x"); err != nil {
return err
}
return sql.ErrTxDone
})
var count int
if err := db.With(func(conn *sql.DB) error {
return conn.QueryRow("SELECT COUNT(*) FROM settings WHERE key = ?", "rollback_key").Scan(&count)
}); err != nil {
t.Fatalf("count query failed: %v", err)
}
if count != 0 {
t.Fatalf("expected rollback, found %d rows", count)
}
}

View File

@@ -0,0 +1,35 @@
package logger
import (
"path/filepath"
"testing"
)
func TestParseLevel(t *testing.T) {
if ParseLevel("debug") != LevelDebug {
t.Fatal("debug should map to LevelDebug")
}
if ParseLevel("warning") != LevelWarn {
t.Fatal("warning should map to LevelWarn")
}
if ParseLevel("unknown") != LevelInfo {
t.Fatal("unknown should default to LevelInfo")
}
}
func TestSetAndGetLevel(t *testing.T) {
SetLevel(LevelError)
if GetLevel() != LevelError {
t.Fatalf("GetLevel() = %v, want %v", GetLevel(), LevelError)
}
}
func TestNewWithFile_CreatesFile(t *testing.T) {
logPath := filepath.Join(t.TempDir(), "runner.log")
l, err := NewWithFile(logPath)
if err != nil {
t.Fatalf("NewWithFile failed: %v", err)
}
defer l.Close()
}

View 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)
}
}

View 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
}

View 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")
}
}

View 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)
}
}

View File

@@ -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

View 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)
}
}

View File

@@ -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{

View 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")
}
}

View File

@@ -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)
}

View 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)
}
}

View 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)
}
}

View File

@@ -0,0 +1,44 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func TestJobConnection_ConnectAndClose(t *testing.T) {
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
var msg map[string]interface{}
if err := conn.ReadJSON(&msg); err != nil {
return
}
if msg["type"] == "auth" {
_ = conn.WriteJSON(map[string]string{"type": "auth_ok"})
}
// Keep open briefly so client can mark connected.
time.Sleep(100 * time.Millisecond)
}))
defer server.Close()
jc := NewJobConnection()
managerURL := strings.Replace(server.URL, "http://", "http://", 1)
if err := jc.Connect(managerURL, "/job/1", "token123"); err != nil {
t.Fatalf("Connect failed: %v", err)
}
if !jc.IsConnected() {
t.Fatal("expected connection to be marked connected")
}
jc.Close()
}

View File

@@ -0,0 +1,45 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNewManagerClient_TrimsTrailingSlash(t *testing.T) {
c := NewManagerClient("http://example.com/")
if c.GetBaseURL() != "http://example.com" {
t.Fatalf("unexpected base url: %q", c.GetBaseURL())
}
}
func TestDoRequest_SetsAuthorizationHeader(t *testing.T) {
var authHeader string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader = r.Header.Get("Authorization")
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}))
defer ts.Close()
c := NewManagerClient(ts.URL)
c.SetCredentials(1, "abc123")
resp, err := c.Request(http.MethodGet, "/x", nil)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if authHeader != "Bearer abc123" {
t.Fatalf("unexpected Authorization header: %q", authHeader)
}
}
func TestRequest_RequiresAuth(t *testing.T) {
c := NewManagerClient("http://example.com")
if _, err := c.Request(http.MethodGet, "/x", nil); err == nil {
t.Fatal("expected auth error when api key is missing")
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -44,8 +45,12 @@ func (m *Manager) GetBinaryPath(version string) (string, error) {
if binaryInfo, err := os.Stat(binaryPath); err == nil {
// Verify it's actually a file (not a directory)
if !binaryInfo.IsDir() {
log.Printf("Found existing Blender %s installation at %s", version, binaryPath)
return binaryPath, nil
absBinaryPath, err := ResolveBinaryPath(binaryPath)
if err != nil {
return "", err
}
log.Printf("Found existing Blender %s installation at %s", version, absBinaryPath)
return absBinaryPath, nil
}
}
// Version folder exists but binary is missing - might be incomplete installation
@@ -72,20 +77,50 @@ func (m *Manager) GetBinaryPath(version string) (string, error) {
return "", fmt.Errorf("blender binary not found after extraction")
}
log.Printf("Blender %s installed at %s", version, binaryPath)
return binaryPath, nil
absBinaryPath, err := ResolveBinaryPath(binaryPath)
if err != nil {
return "", err
}
log.Printf("Blender %s installed at %s", version, absBinaryPath)
return absBinaryPath, nil
}
// GetBinaryForJob returns the Blender binary path for a job.
// Uses the version from metadata or falls back to system blender.
func (m *Manager) GetBinaryForJob(version string) (string, error) {
if version == "" {
return "blender", nil // System blender
return ResolveBinaryPath("blender")
}
return m.GetBinaryPath(version)
}
// ResolveBinaryPath resolves a Blender executable to an absolute path.
func ResolveBinaryPath(blenderBinary string) (string, error) {
if blenderBinary == "" {
return "", fmt.Errorf("blender binary path is empty")
}
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
}
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
}
// TarballEnv returns a copy of baseEnv with LD_LIBRARY_PATH set so that a
// tarball Blender installation can find its bundled libs (e.g. lib/python3.x).
// If blenderBinary is the system "blender" or has no path component, baseEnv is

View File

@@ -0,0 +1,34 @@
package blender
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolveBinaryPath_AbsoluteLikePath(t *testing.T) {
got, err := ResolveBinaryPath("./blender")
if err != nil {
t.Fatalf("ResolveBinaryPath failed: %v", err)
}
if !filepath.IsAbs(got) {
t.Fatalf("expected absolute path, got %q", got)
}
}
func TestResolveBinaryPath_Empty(t *testing.T) {
if _, err := ResolveBinaryPath(""); err == nil {
t.Fatal("expected error for empty blender binary")
}
}
func TestTarballEnv_SetsAndExtendsLDLibraryPath(t *testing.T) {
bin := filepath.Join(string(os.PathSeparator), "tmp", "blender", "blender")
got := TarballEnv(bin, []string{"A=B", "LD_LIBRARY_PATH=/old"})
joined := strings.Join(got, "\n")
if !strings.Contains(joined, "LD_LIBRARY_PATH=/tmp/blender/lib:/old") {
t.Fatalf("expected LD_LIBRARY_PATH to include blender lib, got %v", got)
}
}

View File

@@ -0,0 +1,32 @@
package blender
import "testing"
func TestIsDRMCardNode(t *testing.T) {
tests := map[string]bool{
"card0": true,
"card12": true,
"card": false,
"card0-DP-1": false,
"renderD128": false,
"foo": false,
}
for in, want := range tests {
if got := isDRMCardNode(in); got != want {
t.Fatalf("isDRMCardNode(%q) = %v, want %v", in, got, want)
}
}
}
func TestIsGPUControllerLine(t *testing.T) {
if !isGPUControllerLine("vga compatible controller: nvidia corp") {
t.Fatal("expected VGA controller line to match")
}
if !isGPUControllerLine("3d controller: amd") {
t.Fatal("expected 3d controller line to match")
}
if isGPUControllerLine("audio device: something") {
t.Fatal("audio line should not match")
}
}

View File

@@ -0,0 +1,34 @@
package blender
import (
"testing"
"jiggablend/pkg/types"
)
func TestFilterLog_FiltersNoise(t *testing.T) {
cases := []string{
"",
"--------------------------------------------------------------------",
"Failed to add relation foo",
"BKE_modifier_set_error",
"Depth Type Name",
}
for _, in := range cases {
filtered, level := FilterLog(in)
if !filtered {
t.Fatalf("expected filtered for %q", in)
}
if level != types.LogLevelInfo {
t.Fatalf("unexpected level for %q: %s", in, level)
}
}
}
func TestFilterLog_KeepsNormalLine(t *testing.T) {
filtered, _ := FilterLog("Rendering done.")
if filtered {
t.Fatal("normal line should not be filtered")
}
}

View File

@@ -0,0 +1,10 @@
package blender
import "testing"
func TestVersionString(t *testing.T) {
if got := VersionString(4, 2); got != "4.2" {
t.Fatalf("VersionString() = %q, want %q", got, "4.2")
}
}

View File

@@ -0,0 +1,40 @@
package runner
import (
"encoding/hex"
"testing"
)
func TestNewRunner_InitializesFields(t *testing.T) {
r := New("http://localhost:8080", "runner-a", "host-a", false)
if r == nil {
t.Fatal("New should return a runner")
}
if r.name != "runner-a" || r.hostname != "host-a" {
t.Fatalf("unexpected runner identity: %q %q", r.name, r.hostname)
}
}
func TestRunner_GPUFlagsSetters(t *testing.T) {
r := New("http://localhost:8080", "runner-a", "host-a", false)
r.SetGPULockedOut(true)
if !r.IsGPULockedOut() {
t.Fatal("expected GPU lockout to be true")
}
}
func TestGenerateFingerprint_PopulatesValue(t *testing.T) {
r := New("http://localhost:8080", "runner-a", "host-a", false)
r.generateFingerprint()
fp := r.GetFingerprint()
if fp == "" {
t.Fatal("fingerprint should not be empty")
}
if len(fp) != 64 {
t.Fatalf("fingerprint should be sha256 hex, got %q", fp)
}
if _, err := hex.DecodeString(fp); err != nil {
t.Fatalf("fingerprint should be valid hex: %v", err)
}
}

View File

@@ -385,7 +385,7 @@ func (p *EncodeProcessor) Process(ctx *Context) error {
func detectAlphaChannel(ctx *Context, filePath string) bool {
// Use ffprobe to check pixel format and stream properties
// EXR files with alpha will have formats like gbrapf32le (RGBA) vs gbrpf32le (RGB)
cmd := exec.Command("ffprobe",
cmd := execCommand("ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=pix_fmt:stream=codec_name",
@@ -418,7 +418,7 @@ func detectAlphaChannel(ctx *Context, filePath string) bool {
// detectHDR checks if an EXR file contains HDR content using ffprobe
func detectHDR(ctx *Context, filePath string) bool {
// First, check if the pixel format supports HDR (32-bit float)
cmd := exec.Command("ffprobe",
cmd := execCommand("ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=pix_fmt",
@@ -446,7 +446,7 @@ func detectHDR(ctx *Context, filePath string) bool {
// For 32-bit float EXR, sample pixels to check if values exceed SDR range (> 1.0)
// Use ffmpeg to extract pixel statistics - check max pixel values
// This is more efficient than sampling individual pixels
cmd = exec.Command("ffmpeg",
cmd = execCommand("ffmpeg",
"-v", "error",
"-i", filePath,
"-vf", "signalstats",
@@ -489,7 +489,7 @@ func detectHDRBySampling(ctx *Context, filePath string) bool {
}
for _, region := range sampleRegions {
cmd := exec.Command("ffmpeg",
cmd := execCommand("ffmpeg",
"-v", "error",
"-i", filePath,
"-vf", fmt.Sprintf("%s,scale=1:1", region),

View File

@@ -0,0 +1,120 @@
package tasks
import (
"encoding/binary"
"math"
"os"
"os/exec"
"strings"
"testing"
)
func TestFloat32FromBytes(t *testing.T) {
got := float32FromBytes([]byte{0x00, 0x00, 0x80, 0x3f}) // 1.0 little-endian
if got != 1.0 {
t.Fatalf("float32FromBytes() = %v, want 1.0", got)
}
}
func TestMax(t *testing.T) {
if got := max(1, 2); got != 2 {
t.Fatalf("max() = %v, want 2", got)
}
}
func TestExtractFrameNumber(t *testing.T) {
if got := extractFrameNumber("render_0042.png"); got != 42 {
t.Fatalf("extractFrameNumber() = %d, want 42", got)
}
}
func TestCheckFFmpegSizeError(t *testing.T) {
err := checkFFmpegSizeError("hardware does not support encoding at size ... constraints: width 128-4096 height 128-4096")
if err == nil {
t.Fatal("expected a size error")
}
}
func TestDetectAlphaChannel_UsesExecSeam(t *testing.T) {
orig := execCommand
execCommand = fakeExecCommand
defer func() { execCommand = orig }()
if !detectAlphaChannel(&Context{}, "/tmp/frame.exr") {
t.Fatal("expected alpha channel detection via mocked ffprobe output")
}
}
func TestDetectHDR_UsesExecSeam(t *testing.T) {
orig := execCommand
execCommand = fakeExecCommand
defer func() { execCommand = orig }()
if !detectHDR(&Context{}, "/tmp/frame.exr") {
t.Fatal("expected HDR detection via mocked ffmpeg sampling output")
}
}
func fakeExecCommand(command string, args ...string) *exec.Cmd {
cs := []string{"-test.run=TestExecHelperProcess", "--", command}
cs = append(cs, args...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
return cmd
}
func TestExecHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
idx := 0
for i, a := range os.Args {
if a == "--" {
idx = i
break
}
}
if idx == 0 || idx+1 >= len(os.Args) {
os.Exit(2)
}
cmdName := os.Args[idx+1]
cmdArgs := os.Args[idx+2:]
switch cmdName {
case "ffprobe":
if containsArg(cmdArgs, "stream=pix_fmt:stream=codec_name") {
_, _ = os.Stdout.WriteString("pix_fmt=gbrapf32le\ncodec_name=exr\n")
os.Exit(0)
}
_, _ = os.Stdout.WriteString("gbrpf32le\n")
os.Exit(0)
case "ffmpeg":
if containsArg(cmdArgs, "signalstats") {
_, _ = os.Stderr.WriteString("signalstats failed")
os.Exit(1)
}
if containsArg(cmdArgs, "rawvideo") {
buf := make([]byte, 12)
binary.LittleEndian.PutUint32(buf[0:4], math.Float32bits(1.5))
binary.LittleEndian.PutUint32(buf[4:8], math.Float32bits(0.2))
binary.LittleEndian.PutUint32(buf[8:12], math.Float32bits(0.1))
_, _ = os.Stdout.Write(buf)
os.Exit(0)
}
os.Exit(0)
default:
os.Exit(0)
}
}
func containsArg(args []string, target string) bool {
for _, a := range args {
if strings.Contains(a, target) {
return true
}
}
return false
}

View File

@@ -0,0 +1,7 @@
package tasks
import "os/exec"
// execCommand is a seam for process execution in tests.
var execCommand = exec.Command

View File

@@ -0,0 +1,42 @@
package tasks
import (
"errors"
"testing"
"jiggablend/pkg/types"
)
func TestNewContext_NormalizesFrameEnd(t *testing.T) {
ctx := NewContext(1, 2, "job", 10, 1, "render", "/tmp", "tok", nil, nil, nil, nil, nil, nil, nil, false, false, false, false, false, false, nil)
if ctx.FrameEnd != 10 {
t.Fatalf("expected FrameEnd to be normalized to Frame, got %d", ctx.FrameEnd)
}
}
func TestContext_GetOutputFormat_Default(t *testing.T) {
ctx := &Context{}
if got := ctx.GetOutputFormat(); got != "PNG" {
t.Fatalf("GetOutputFormat() = %q, want PNG", got)
}
}
func TestContext_ShouldForceCPU(t *testing.T) {
ctx := &Context{ForceCPURendering: true}
if !ctx.ShouldForceCPU() {
t.Fatal("expected force cpu when runner-level flag is set")
}
force := true
ctx = &Context{Metadata: &types.BlendMetadata{RenderSettings: types.RenderSettings{EngineSettings: map[string]interface{}{"force_cpu": force}}}}
if !ctx.ShouldForceCPU() {
t.Fatal("expected force cpu when metadata requests it")
}
}
func TestErrJobCancelled_IsSentinel(t *testing.T) {
if !errors.Is(ErrJobCancelled, ErrJobCancelled) {
t.Fatal("sentinel error should be self-identical")
}
}

View File

@@ -88,6 +88,11 @@ func (p *RenderProcessor) Process(ctx *Context) error {
ctx.Info("No Blender version specified, using system blender")
}
blenderBinary, err = blender.ResolveBinaryPath(blenderBinary)
if err != nil {
return fmt.Errorf("failed to resolve blender binary: %w", err)
}
// Create output directory
outputDir := filepath.Join(ctx.WorkDir, "output")
if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -202,8 +207,16 @@ func (p *RenderProcessor) createRenderScript(ctx *Context, renderFormat string)
func (p *RenderProcessor) runBlender(ctx *Context, blenderBinary, blendFile, outputDir, renderFormat, blenderHome string) error {
scriptPath := filepath.Join(ctx.WorkDir, "enable_gpu.py")
blendFileAbs, err := filepath.Abs(blendFile)
if err != nil {
return fmt.Errorf("failed to resolve blend file path: %w", err)
}
scriptPathAbs, err := filepath.Abs(scriptPath)
if err != nil {
return fmt.Errorf("failed to resolve blender script path: %w", err)
}
args := []string{"-b", blendFile, "--python", scriptPath}
args := []string{"-b", blendFileAbs, "--python", scriptPathAbs}
if ctx.ShouldEnableExecution() {
args = append(args, "--enable-autoexec")
}
@@ -220,7 +233,7 @@ func (p *RenderProcessor) runBlender(ctx *Context, blenderBinary, blendFile, out
args = append(args, "-f", fmt.Sprintf("%d", ctx.Frame))
}
cmd := exec.Command(blenderBinary, args...)
cmd := execCommand(blenderBinary, args...)
cmd.Dir = ctx.WorkDir
// Set up environment: LD_LIBRARY_PATH for tarball Blender, then custom HOME

View File

@@ -0,0 +1,28 @@
package tasks
import "testing"
func TestCheckGPUErrorLine_TriggersCallback(t *testing.T) {
p := NewRenderProcessor()
triggered := false
ctx := &Context{
OnGPUError: func() { triggered = true },
}
p.checkGPUErrorLine(ctx, "Fatal: Illegal address in HIP kernel execution")
if !triggered {
t.Fatal("expected GPU error callback to be triggered")
}
}
func TestCheckGPUErrorLine_IgnoresNormalLine(t *testing.T) {
p := NewRenderProcessor()
triggered := false
ctx := &Context{
OnGPUError: func() { triggered = true },
}
p.checkGPUErrorLine(ctx, "Render completed successfully")
if triggered {
t.Fatal("did not expect GPU callback for normal line")
}
}

View File

@@ -99,3 +99,27 @@ func TestExtractTar_PathTraversal(t *testing.T) {
t.Fatal("expected error for path traversal, got nil")
}
}
func TestExtractTarFile(t *testing.T) {
destDir := t.TempDir()
tarPath := filepath.Join(t.TempDir(), "archive.tar")
buf := createTarBuffer(map[string]string{
"hello.txt": "world",
})
if err := os.WriteFile(tarPath, buf.Bytes(), 0644); err != nil {
t.Fatalf("write tar file: %v", err)
}
if err := ExtractTarFile(tarPath, destDir); err != nil {
t.Fatalf("ExtractTarFile: %v", err)
}
got, err := os.ReadFile(filepath.Join(destDir, "hello.txt"))
if err != nil {
t.Fatalf("read extracted file: %v", err)
}
if string(got) != "world" {
t.Fatalf("unexpected file content: %q", got)
}
}

View File

@@ -0,0 +1,40 @@
package workspace
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSanitizeName_ReplacesUnsafeChars(t *testing.T) {
got := sanitizeName("runner / with\\bad:chars")
if strings.ContainsAny(got, " /\\:") {
t.Fatalf("sanitizeName did not sanitize input: %q", got)
}
}
func TestFindBlendFiles_IgnoresBlendSaveFiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "scene.blend"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "scene.blend1"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
files, err := FindBlendFiles(dir)
if err != nil {
t.Fatalf("FindBlendFiles failed: %v", err)
}
if len(files) != 1 || files[0] != "scene.blend" {
t.Fatalf("unexpected files: %#v", files)
}
}
func TestFindFirstBlendFile_ReturnsErrorWhenMissing(t *testing.T) {
_, err := FindFirstBlendFile(t.TempDir())
if err == nil {
t.Fatal("expected error when no blend file exists")
}
}

View File

@@ -4,7 +4,9 @@ import (
"errors"
"io"
"os"
"os/exec"
"testing"
"time"
)
func TestIsBenignPipeReadError(t *testing.T) {
@@ -30,3 +32,24 @@ func TestIsBenignPipeReadError(t *testing.T) {
})
}
}
func TestProcessTracker_TrackUntrack(t *testing.T) {
pt := NewProcessTracker()
cmd := exec.Command("sh", "-c", "sleep 1")
pt.Track(1, cmd)
if count := pt.Count(); count != 1 {
t.Fatalf("Count() = %d, want 1", count)
}
pt.Untrack(1)
if count := pt.Count(); count != 0 {
t.Fatalf("Count() = %d, want 0", count)
}
}
func TestRunCommandWithTimeout_TimesOut(t *testing.T) {
pt := NewProcessTracker()
_, err := RunCommandWithTimeout(200*time.Millisecond, "sh", []string{"-c", "sleep 2"}, "", nil, 99, pt)
if err == nil {
t.Fatal("expected timeout error")
}
}

View File

@@ -0,0 +1,19 @@
package scripts
import (
"strings"
"testing"
)
func TestEmbeddedScripts_ArePresent(t *testing.T) {
if strings.TrimSpace(ExtractMetadata) == "" {
t.Fatal("ExtractMetadata script should not be empty")
}
if strings.TrimSpace(UnhideObjects) == "" {
t.Fatal("UnhideObjects script should not be empty")
}
if strings.TrimSpace(RenderBlenderTemplate) == "" {
t.Fatal("RenderBlenderTemplate should not be empty")
}
}

49
pkg/types/types_test.go Normal file
View File

@@ -0,0 +1,49 @@
package types
import (
"encoding/json"
"testing"
"time"
)
func TestJobJSON_RoundTrip(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
frameStart, frameEnd := 1, 10
format := "PNG"
job := Job{
ID: 42,
UserID: 7,
JobType: JobTypeRender,
Name: "demo",
Status: JobStatusPending,
Progress: 12.5,
FrameStart: &frameStart,
FrameEnd: &frameEnd,
OutputFormat: &format,
BlendMetadata: &BlendMetadata{
FrameStart: 1,
FrameEnd: 10,
RenderSettings: RenderSettings{
ResolutionX: 1920,
ResolutionY: 1080,
FrameRate: 24.0,
},
},
CreatedAt: now,
}
raw, err := json.Marshal(job)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var out Job
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if out.ID != job.ID || out.JobType != JobTypeRender || out.BlendMetadata == nil {
t.Fatalf("unexpected roundtrip result: %+v", out)
}
}

19
version/version_test.go Normal file
View File

@@ -0,0 +1,19 @@
package version
import (
"strings"
"testing"
)
func TestInitDefaults_AreSet(t *testing.T) {
if Version == "" {
t.Fatal("Version should be initialized")
}
if Date == "" {
t.Fatal("Date should be initialized")
}
if !strings.Contains(Version, ".") {
t.Fatalf("Version should look semantic-ish, got %q", Version)
}
}

30
web/embed_test.go Normal file
View File

@@ -0,0 +1,30 @@
package web
import (
"net/http/httptest"
"testing"
)
func TestGetStaticFileSystem_NonNil(t *testing.T) {
fs := GetStaticFileSystem()
if fs == nil {
t.Fatal("static filesystem should not be nil")
}
}
func TestStaticHandler_ServesWithoutPanic(t *testing.T) {
h := StaticHandler()
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/assets/does-not-exist.txt", nil)
h.ServeHTTP(rr, req)
if rr.Code == 0 {
t.Fatal("handler should write a status code")
}
}
func TestGetTemplateFS_NonNil(t *testing.T) {
if GetTemplateFS() == nil {
t.Fatal("template fs should not be nil")
}
}

View File

@@ -169,11 +169,27 @@
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || "Job creation failed");
const err = new Error(data.error || "Job creation failed");
if (data && typeof data.code === "string") {
err.code = data.code;
}
throw err;
}
return data;
}
function resetToUploadStep(message) {
sessionID = "";
clearInterval(pollTimer);
setUploadBusy(false);
mainBlendWrapper.classList.add("hidden");
metadataPreview.innerHTML = "";
configSection.classList.add("hidden");
setStep(1);
showStatus("Please upload the file again.");
showError(message);
}
async function runSubmission(mainBlendFile) {
showError("");
setStep(1);
@@ -277,6 +293,14 @@
showError("");
await submitJobConfig();
} catch (err) {
if (err && err.code === "UPLOAD_SESSION_EXPIRED") {
resetToUploadStep(err.message || "Upload session expired. Please upload the file again.");
return;
}
if (err && err.code === "UPLOAD_SESSION_NOT_READY") {
showError(err.message || "Upload session is still processing. Please wait and try again.");
return;
}
showError(err.message || "Failed to create job");
}
});