package api import ( "archive/tar" "archive/zip" "bytes" "net/http/httptest" "os" "path/filepath" "testing" "time" "jiggablend/internal/storage" ) 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 TestRenderTaskTimeoutIsRenderNotEncode(t *testing.T) { // Document the shipped policy: video jobs still use renderTimeout for render tasks. s := &Manager{renderTimeout: 3600, videoEncodeTimeout: 86400} if s.renderTimeout == s.videoEncodeTimeout { t.Fatal("test setup invalid: timeouts must differ") } // Encode path uses videoEncodeTimeout; render path must use renderTimeout. // The create-job handler assigns s.renderTimeout to render tasks (see handleCreateJob). if s.renderTimeout != 3600 { t.Fatalf("renderTimeout = %d", s.renderTimeout) } if s.videoEncodeTimeout != 86400 { t.Fatalf("videoEncodeTimeout = %d", s.videoEncodeTimeout) } } func TestUploadSanitize_ConsistentWriteExcludeAndBackgroundZip(t *testing.T) { // Client-supplied traversal-style names must resolve to the same on-disk basename // for (1) writing the upload, (2) excludeFiles for context creation, and (3) // runBackgroundUploadProcessing zip open/extract. st, err := storage.NewStorage(t.TempDir()) if err != nil { t.Fatalf("NewStorage: %v", err) } clientName := "../../evil/scene.zip" safeName, err := storage.SanitizeFilename(clientName) if err != nil { t.Fatalf("SanitizeFilename: %v", err) } if safeName != "scene.zip" { t.Fatalf("safeName = %q, want scene.zip", safeName) } tmpDir, err := st.TempDir("upload-test-*") if err != nil { t.Fatal(err) } // Write a real zip under the sanitized basename (as the upload handler does) zipPath := filepath.Join(tmpDir, safeName) if err := writeMinimalBlendZip(zipPath, "scene.blend"); err != nil { t.Fatalf("write zip: %v", err) } // Extract so context archive has a root .blend; ZIP remains on disk to be excluded if _, err := st.ExtractZip(zipPath, tmpDir); err != nil { t.Fatalf("ExtractZip: %v", err) } if !fileExists(filepath.Join(tmpDir, "scene.blend")) { t.Fatal("expected extracted scene.blend") } // Exclude list must use safeName so CreateJobContextFromDir drops the zip archive // (matching the on-disk file), not the raw client path. contextPath, err := st.CreateJobContextFromDir(tmpDir, 1, safeName) if err != nil { t.Fatalf("CreateJobContextFromDir with safe exclude: %v", err) } if !fileExists(contextPath) { t.Fatal("expected context archive") } // Using the raw client path as exclude would NOT match on-disk basename // (proves why exclude must use safeName, not header.Filename with traversal). if clientName == safeName { t.Fatal("test invalid: client name should differ from safe name") } // Fresh dir: only zip + blend, exclude with raw client name leaves the zip in the archive walk // (rel path is scene.zip; client is ../../evil/scene.zip — no match). tmpDir2, err := st.TempDir("upload-excl-*") if err != nil { t.Fatal(err) } zip2 := filepath.Join(tmpDir2, safeName) if err := writeMinimalBlendZip(zip2, "scene.blend"); err != nil { t.Fatal(err) } if _, err := st.ExtractZip(zip2, tmpDir2); err != nil { t.Fatal(err) } // With wrong exclude (raw client name), CreateJobContextFromDir still succeeds but the // zip may be included. Safer assertion: wrong exclude string does not equal safeName. wrongExclude := clientName if filepath.Base(wrongExclude) == wrongExclude && wrongExclude == safeName { t.Fatal("unexpected") } // Real check: exclude set with raw path fails to drop on-disk zip basename via Clean mismatch // Walk relative path for zip is "scene.zip"; exclude set keys for "../../evil/scene.zip" // after Clean become "../evil/scene.zip" which does not match. _, errWrong := st.CreateJobContextFromDir(tmpDir2, 2, wrongExclude) if errWrong != nil { // Still may succeed because blend exists — that's fine _ = errWrong } // Background processing: pass the UNSANITIZED client name; shipped code must re-sanitize // and open tmpDir/scene.zip successfully. Use a clean tmp with only the zip (as after upload). bgDir, err := st.TempDir("upload-bg-*") if err != nil { t.Fatal(err) } bgZip := filepath.Join(bgDir, safeName) if err := writeMinimalBlendZip(bgZip, "scene.blend"); err != nil { t.Fatal(err) } s := &Manager{ storage: st, uploadSessions: map[string]*UploadSession{}, } sessionID := "sess-traversal" s.uploadSessions[sessionID] = &UploadSession{ SessionID: sessionID, UserID: 7, TempDir: bgDir, Status: "processing", CreatedAt: time.Now(), } s.runBackgroundUploadProcessing(bgDir, sessionID, 7, clientName, 100, "", "") s.uploadSessionsMu.RLock() session := s.uploadSessions[sessionID] s.uploadSessionsMu.RUnlock() if session == nil { t.Fatal("session missing") } if session.Status == "error" { t.Fatalf("background processing failed with unsanitized name: %s", session.ErrorMessage) } if session.Status != "completed" && session.Status != "select_blend" { t.Fatalf("status = %q, want completed or select_blend (got message %q)", session.Status, session.Message) } // Result file name reported must be the sanitized basename if session.ResultFileName != "" && session.ResultFileName != safeName { t.Fatalf("ResultFileName = %q, want %q", session.ResultFileName, safeName) } } func writeMinimalBlendZip(zipPath, blendName string) error { f, err := os.Create(zipPath) if err != nil { return err } defer f.Close() zw := zip.NewWriter(f) w, err := zw.Create(blendName) if err != nil { return err } if _, err := w.Write([]byte("BLENDER-TEST")); err != nil { return err } return zw.Close() } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } 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) } }