ResumeLens/internal/api/security_test.go
2026-04-07 13:14:52 -07:00

189 lines
5.3 KiB
Go

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 := "<script>alert('xss')</script> 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)
}
}