package api import ( "bytes" "io" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "git.gophernest.net/azpect/ResumeLens/internal/handlers" "git.gophernest.net/azpect/ResumeLens/internal/models" ) func makeAnalyzeRequestWithJob(t *testing.T, ip, job string) *http.Request { t.Helper() body := &bytes.Buffer{} w := multipart.NewWriter(body) part, err := w.CreateFormFile("resume", "resume.pdf") if err != nil { t.Fatalf("create form file: %v", err) } if _, err := part.Write([]byte("fake-pdf")); err != nil { t.Fatalf("write form file: %v", err) } if err := w.WriteField("job_description", job); err != nil { t.Fatalf("write field: %v", err) } if err := w.Close(); err != nil { t.Fatalf("close writer: %v", err) } req := httptest.NewRequest(http.MethodPost, "/api/analyze", body) req.Header.Set("Content-Type", w.FormDataContentType()) req.RemoteAddr = ip + ":12345" return req } func TestSecurity_9_1_1_APIKeyNeverInResponses(t *testing.T) { secret := "sk-test-super-secret-123" resetRateLimiter() restore := handlers.SetAnalyzeResumeForTesting(func(_ io.Reader, _ string) (*models.AnalysisResult, error) { return &models.AnalysisResult{OverallScore: 80, Summary: "ok"}, nil }) t.Cleanup(restore) r := chi.NewRouter() Mount(r) success := httptest.NewRecorder() r.ServeHTTP(success, makeAnalyzeRequestWithJob(t, "127.0.0.1", "Go role")) if strings.Contains(success.Body.String(), secret) { t.Fatalf("secret leaked in success response") } restoreErr := handlers.SetAnalyzeResumeForTesting(func(_ io.Reader, _ string) (*models.AnalysisResult, error) { return nil, io.ErrUnexpectedEOF }) t.Cleanup(restoreErr) errResp := httptest.NewRecorder() r.ServeHTTP(errResp, makeAnalyzeRequestWithJob(t, "127.0.0.2", "Go role")) if strings.Contains(errResp.Body.String(), secret) { t.Fatalf("secret leaked in error response") } } func TestSecurity_9_1_2_APIKeyNotInErrorMessages(t *testing.T) { secret := "sk-test-never-leak" resetRateLimiter() restore := handlers.SetAnalyzeResumeForTesting(func(_ io.Reader, _ string) (*models.AnalysisResult, error) { return nil, io.ErrUnexpectedEOF }) t.Cleanup(restore) r := chi.NewRouter() Mount(r) rr := httptest.NewRecorder() r.ServeHTTP(rr, makeAnalyzeRequestWithJob(t, "127.0.0.1", "Go role")) if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", rr.Code) } if strings.Contains(rr.Body.String(), secret) { t.Fatalf("secret leaked in error message") } } func TestSecurity_9_3_1_NoInputSanitization_PassedAsIs(t *testing.T) { malicious := " SELECT * FROM users;" resetRateLimiter() restore := handlers.SetAnalyzeResumeForTesting(func(_ io.Reader, jobDescription string) (*models.AnalysisResult, error) { if jobDescription != malicious { t.Fatalf("expected exact unsanitized input, got %q", jobDescription) } return &models.AnalysisResult{OverallScore: 80, Summary: "ok"}, nil }) t.Cleanup(restore) r := chi.NewRouter() Mount(r) rr := httptest.NewRecorder() r.ServeHTTP(rr, makeAnalyzeRequestWithJob(t, "127.0.0.1", malicious)) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } } func TestSecurity_9_3_2_MaliciousInputHandledAsPlainText(t *testing.T) { payload := "'; DROP TABLE resumes; -- $(rm -rf /)" resetRateLimiter() restore := handlers.SetAnalyzeResumeForTesting(func(_ io.Reader, jobDescription string) (*models.AnalysisResult, error) { if jobDescription != payload { t.Fatalf("expected exact payload as plain text, got %q", jobDescription) } return &models.AnalysisResult{OverallScore: 70, Summary: "ok"}, nil }) t.Cleanup(restore) r := chi.NewRouter() Mount(r) rr := httptest.NewRecorder() r.ServeHTTP(rr, makeAnalyzeRequestWithJob(t, "127.0.0.1", payload)) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } } func TestSecurity_9_4_1_NoAuthenticationRequired(t *testing.T) { resetRateLimiter() restore := handlers.SetAnalyzeResumeForTesting(func(_ io.Reader, _ string) (*models.AnalysisResult, error) { return &models.AnalysisResult{OverallScore: 80, Summary: "ok"}, nil }) t.Cleanup(restore) r := chi.NewRouter() Mount(r) req := makeAnalyzeRequestWithJob(t, "127.0.0.1", "Go role") req.Header.Del("Authorization") rr := httptest.NewRecorder() r.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected anonymous request to succeed, got %d", rr.Code) } } func TestSecurity_9_4_2_RateLimitingIsOnlyProtection(t *testing.T) { resetRateLimiter() restore := handlers.SetAnalyzeResumeForTesting(func(_ io.Reader, _ string) (*models.AnalysisResult, error) { return &models.AnalysisResult{OverallScore: 80, Summary: "ok"}, nil }) t.Cleanup(restore) r := chi.NewRouter() Mount(r) for i := 0; i < 10; i++ { req := makeAnalyzeRequestWithJob(t, "127.0.0.1", "Go role") req.Header.Set("Authorization", "Bearer whatever") rr := httptest.NewRecorder() r.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("request %d expected 200, got %d", i+1, rr.Code) } } blockedReq := makeAnalyzeRequestWithJob(t, "127.0.0.1", "Go role") blockedReq.Header.Set("Authorization", "Bearer admin") blocked := httptest.NewRecorder() r.ServeHTTP(blocked, blockedReq) if blocked.Code != http.StatusTooManyRequests { t.Fatalf("expected 429 even with auth header, got %d", blocked.Code) } }