From a3defe5cf6a4b8c517699f0d41ca817db89e158c Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Sat, 14 Mar 2026 22:20:03 -0500 Subject: [PATCH] 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. --- cmd/jiggablend/cmd/manager.go | 11 +- cmd/jiggablend/cmd/managerconfig_test.go | 23 ++++ cmd/jiggablend/cmd/root_test.go | 16 +++ cmd/jiggablend/cmd/runner_test.go | 17 +++ cmd/jiggablend/cmd/version_test.go | 13 ++ cmd/jiggablend/main_test.go | 8 ++ internal/auth/auth_test.go | 56 ++++++++ internal/auth/jobtoken_test.go | 84 ++++++++++++ internal/auth/secrets_test.go | 32 +++++ internal/config/config_test.go | 66 +++++++++ internal/database/schema_test.go | 58 ++++++++ internal/logger/logger_test.go | 35 +++++ internal/manager/admin_test.go | 35 +++++ internal/manager/blender_path.go | 35 +++++ internal/manager/blender_path_test.go | 23 ++++ internal/manager/blender_test.go | 27 ++++ internal/manager/jobs.go | 133 ++++++++++++++---- internal/manager/jobs_test.go | 145 ++++++++++++++++++++ internal/manager/manager.go | 7 + internal/manager/manager_test.go | 50 +++++++ internal/manager/metadata.go | 40 ++++-- internal/manager/metadata_test.go | 98 +++++++++++++ internal/manager/runners_test.go | 21 +++ internal/runner/api/jobconn_test.go | 44 ++++++ internal/runner/api/manager_test.go | 45 ++++++ internal/runner/blender/binary.go | 45 +++++- internal/runner/blender/binary_test.go | 34 +++++ internal/runner/blender/detect_test.go | 32 +++++ internal/runner/blender/logfilter_test.go | 34 +++++ internal/runner/blender/version_test.go | 10 ++ internal/runner/runner_test.go | 40 ++++++ internal/runner/tasks/encode.go | 8 +- internal/runner/tasks/encode_test.go | 120 ++++++++++++++++ internal/runner/tasks/exec_seams.go | 7 + internal/runner/tasks/processor_test.go | 42 ++++++ internal/runner/tasks/render.go | 17 ++- internal/runner/tasks/render_test.go | 28 ++++ internal/runner/workspace/archive_test.go | 24 ++++ internal/runner/workspace/workspace_test.go | 40 ++++++ pkg/executils/exec_test.go | 23 ++++ pkg/scripts/scripts_test.go | 19 +++ pkg/types/types_test.go | 49 +++++++ version/version_test.go | 19 +++ web/embed_test.go | 30 ++++ web/static/job_new.js | 26 +++- 45 files changed, 1717 insertions(+), 52 deletions(-) create mode 100644 cmd/jiggablend/cmd/managerconfig_test.go create mode 100644 cmd/jiggablend/cmd/root_test.go create mode 100644 cmd/jiggablend/cmd/runner_test.go create mode 100644 cmd/jiggablend/cmd/version_test.go create mode 100644 cmd/jiggablend/main_test.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/jobtoken_test.go create mode 100644 internal/auth/secrets_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/database/schema_test.go create mode 100644 internal/logger/logger_test.go create mode 100644 internal/manager/admin_test.go create mode 100644 internal/manager/blender_path.go create mode 100644 internal/manager/blender_path_test.go create mode 100644 internal/manager/blender_test.go create mode 100644 internal/manager/jobs_test.go create mode 100644 internal/manager/manager_test.go create mode 100644 internal/manager/metadata_test.go create mode 100644 internal/manager/runners_test.go create mode 100644 internal/runner/api/jobconn_test.go create mode 100644 internal/runner/api/manager_test.go create mode 100644 internal/runner/blender/binary_test.go create mode 100644 internal/runner/blender/detect_test.go create mode 100644 internal/runner/blender/logfilter_test.go create mode 100644 internal/runner/blender/version_test.go create mode 100644 internal/runner/runner_test.go create mode 100644 internal/runner/tasks/encode_test.go create mode 100644 internal/runner/tasks/exec_seams.go create mode 100644 internal/runner/tasks/processor_test.go create mode 100644 internal/runner/tasks/render_test.go create mode 100644 internal/runner/workspace/workspace_test.go create mode 100644 pkg/scripts/scripts_test.go create mode 100644 pkg/types/types_test.go create mode 100644 version/version_test.go create mode 100644 web/embed_test.go diff --git a/cmd/jiggablend/cmd/manager.go b/cmd/jiggablend/cmd/manager.go index 4f37aca..a595ef4 100644 --- a/cmd/jiggablend/cmd/manager.go +++ b/cmd/jiggablend/cmd/manager.go @@ -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)) diff --git a/cmd/jiggablend/cmd/managerconfig_test.go b/cmd/jiggablend/cmd/managerconfig_test.go new file mode 100644 index 0000000..e1d0e79 --- /dev/null +++ b/cmd/jiggablend/cmd/managerconfig_test.go @@ -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)) + } +} + diff --git a/cmd/jiggablend/cmd/root_test.go b/cmd/jiggablend/cmd/root_test.go new file mode 100644 index 0000000..75d5e37 --- /dev/null +++ b/cmd/jiggablend/cmd/root_test.go @@ -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) + } + } +} + diff --git a/cmd/jiggablend/cmd/runner_test.go b/cmd/jiggablend/cmd/runner_test.go new file mode 100644 index 0000000..72479c8 --- /dev/null +++ b/cmd/jiggablend/cmd/runner_test.go @@ -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) + } +} + diff --git a/cmd/jiggablend/cmd/version_test.go b/cmd/jiggablend/cmd/version_test.go new file mode 100644 index 0000000..88139c7 --- /dev/null +++ b/cmd/jiggablend/cmd/version_test.go @@ -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") + } +} + diff --git a/cmd/jiggablend/main_test.go b/cmd/jiggablend/main_test.go new file mode 100644 index 0000000..3efb302 --- /dev/null +++ b/cmd/jiggablend/main_test.go @@ -0,0 +1,8 @@ +package main + +import "testing" + +func TestMainPackage_Builds(t *testing.T) { + // Smoke test placeholder to keep package main under test compilation. +} + diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..a90faf4 --- /dev/null +++ b/internal/auth/auth_test.go @@ -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") + } +} + diff --git a/internal/auth/jobtoken_test.go b/internal/auth/jobtoken_test.go new file mode 100644 index 0000000..44d4e71 --- /dev/null +++ b/internal/auth/jobtoken_test.go @@ -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) +} + diff --git a/internal/auth/secrets_test.go b/internal/auth/secrets_test.go new file mode 100644 index 0000000..a82d400 --- /dev/null +++ b/internal/auth/secrets_test.go @@ -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) + } +} + diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b22604c --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} + diff --git a/internal/database/schema_test.go b/internal/database/schema_test.go new file mode 100644 index 0000000..4cabb5a --- /dev/null +++ b/internal/database/schema_test.go @@ -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) + } +} + diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 0000000..c84259a --- /dev/null +++ b/internal/logger/logger_test.go @@ -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() +} + diff --git a/internal/manager/admin_test.go b/internal/manager/admin_test.go new file mode 100644 index 0000000..d7cf488 --- /dev/null +++ b/internal/manager/admin_test.go @@ -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) + } +} + diff --git a/internal/manager/blender_path.go b/internal/manager/blender_path.go new file mode 100644 index 0000000..cb73d73 --- /dev/null +++ b/internal/manager/blender_path.go @@ -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 +} diff --git a/internal/manager/blender_path_test.go b/internal/manager/blender_path_test.go new file mode 100644 index 0000000..f3d306d --- /dev/null +++ b/internal/manager/blender_path_test.go @@ -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") + } +} + diff --git a/internal/manager/blender_test.go b/internal/manager/blender_test.go new file mode 100644 index 0000000..9798afe --- /dev/null +++ b/internal/manager/blender_test.go @@ -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) + } +} + diff --git a/internal/manager/jobs.go b/internal/manager/jobs.go index 08f695d..b74c1ec 100644 --- a/internal/manager/jobs.go +++ b/internal/manager/jobs.go @@ -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 diff --git a/internal/manager/jobs_test.go b/internal/manager/jobs_test.go new file mode 100644 index 0000000..8bf6001 --- /dev/null +++ b/internal/manager/jobs_test.go @@ -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) + } +} + diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 4f4261a..c7aa49b 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -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{ diff --git a/internal/manager/manager_test.go b/internal/manager/manager_test.go new file mode 100644 index 0000000..2034318 --- /dev/null +++ b/internal/manager/manager_test.go @@ -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") + } +} + diff --git a/internal/manager/metadata.go b/internal/manager/metadata.go index dbdf467..d964559 100644 --- a/internal/manager/metadata.go +++ b/internal/manager/metadata.go @@ -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) } diff --git a/internal/manager/metadata_test.go b/internal/manager/metadata_test.go new file mode 100644 index 0000000..78f8aff --- /dev/null +++ b/internal/manager/metadata_test.go @@ -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) + } +} + diff --git a/internal/manager/runners_test.go b/internal/manager/runners_test.go new file mode 100644 index 0000000..8570a94 --- /dev/null +++ b/internal/manager/runners_test.go @@ -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) + } +} + diff --git a/internal/runner/api/jobconn_test.go b/internal/runner/api/jobconn_test.go new file mode 100644 index 0000000..77415c3 --- /dev/null +++ b/internal/runner/api/jobconn_test.go @@ -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() +} + diff --git a/internal/runner/api/manager_test.go b/internal/runner/api/manager_test.go new file mode 100644 index 0000000..0dd89fa --- /dev/null +++ b/internal/runner/api/manager_test.go @@ -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") + } +} + diff --git a/internal/runner/blender/binary.go b/internal/runner/blender/binary.go index 9e42e20..3bf51af 100644 --- a/internal/runner/blender/binary.go +++ b/internal/runner/blender/binary.go @@ -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 diff --git a/internal/runner/blender/binary_test.go b/internal/runner/blender/binary_test.go new file mode 100644 index 0000000..c55618c --- /dev/null +++ b/internal/runner/blender/binary_test.go @@ -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) + } +} + diff --git a/internal/runner/blender/detect_test.go b/internal/runner/blender/detect_test.go new file mode 100644 index 0000000..52b1c35 --- /dev/null +++ b/internal/runner/blender/detect_test.go @@ -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") + } +} + diff --git a/internal/runner/blender/logfilter_test.go b/internal/runner/blender/logfilter_test.go new file mode 100644 index 0000000..ed5ce70 --- /dev/null +++ b/internal/runner/blender/logfilter_test.go @@ -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") + } +} + diff --git a/internal/runner/blender/version_test.go b/internal/runner/blender/version_test.go new file mode 100644 index 0000000..d650c47 --- /dev/null +++ b/internal/runner/blender/version_test.go @@ -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") + } +} + diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 0000000..15b3d36 --- /dev/null +++ b/internal/runner/runner_test.go @@ -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) + } +} + diff --git a/internal/runner/tasks/encode.go b/internal/runner/tasks/encode.go index 9288140..7d68d16 100644 --- a/internal/runner/tasks/encode.go +++ b/internal/runner/tasks/encode.go @@ -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), diff --git a/internal/runner/tasks/encode_test.go b/internal/runner/tasks/encode_test.go new file mode 100644 index 0000000..3cb5c9e --- /dev/null +++ b/internal/runner/tasks/encode_test.go @@ -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 +} + diff --git a/internal/runner/tasks/exec_seams.go b/internal/runner/tasks/exec_seams.go new file mode 100644 index 0000000..8b283a5 --- /dev/null +++ b/internal/runner/tasks/exec_seams.go @@ -0,0 +1,7 @@ +package tasks + +import "os/exec" + +// execCommand is a seam for process execution in tests. +var execCommand = exec.Command + diff --git a/internal/runner/tasks/processor_test.go b/internal/runner/tasks/processor_test.go new file mode 100644 index 0000000..cedc2d9 --- /dev/null +++ b/internal/runner/tasks/processor_test.go @@ -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") + } +} + diff --git a/internal/runner/tasks/render.go b/internal/runner/tasks/render.go index d27f278..4f2e311 100644 --- a/internal/runner/tasks/render.go +++ b/internal/runner/tasks/render.go @@ -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 diff --git a/internal/runner/tasks/render_test.go b/internal/runner/tasks/render_test.go new file mode 100644 index 0000000..f87a8c4 --- /dev/null +++ b/internal/runner/tasks/render_test.go @@ -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") + } +} + diff --git a/internal/runner/workspace/archive_test.go b/internal/runner/workspace/archive_test.go index a5a3971..6edfcc2 100644 --- a/internal/runner/workspace/archive_test.go +++ b/internal/runner/workspace/archive_test.go @@ -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) + } +} diff --git a/internal/runner/workspace/workspace_test.go b/internal/runner/workspace/workspace_test.go new file mode 100644 index 0000000..98661eb --- /dev/null +++ b/internal/runner/workspace/workspace_test.go @@ -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") + } +} + diff --git a/pkg/executils/exec_test.go b/pkg/executils/exec_test.go index 8ac1bb8..061e264 100644 --- a/pkg/executils/exec_test.go +++ b/pkg/executils/exec_test.go @@ -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") + } +} diff --git a/pkg/scripts/scripts_test.go b/pkg/scripts/scripts_test.go new file mode 100644 index 0000000..91e2f21 --- /dev/null +++ b/pkg/scripts/scripts_test.go @@ -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") + } +} + diff --git a/pkg/types/types_test.go b/pkg/types/types_test.go new file mode 100644 index 0000000..aad67d7 --- /dev/null +++ b/pkg/types/types_test.go @@ -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) + } +} + diff --git a/version/version_test.go b/version/version_test.go new file mode 100644 index 0000000..f8685a4 --- /dev/null +++ b/version/version_test.go @@ -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) + } +} + diff --git a/web/embed_test.go b/web/embed_test.go new file mode 100644 index 0000000..f0d4f89 --- /dev/null +++ b/web/embed_test.go @@ -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") + } +} + diff --git a/web/static/job_new.js b/web/static/job_new.js index c17aa7d..1ca4016 100644 --- a/web/static/job_new.js +++ b/web/static/job_new.js @@ -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"); } });