189 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|