Compare commits
8 Commits
85882bae14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e58ba1b8d1 | |||
| 4ffc601723 | |||
| d6321e775a | |||
| de9b544afb | |||
| 19291b7f61 | |||
| c31eb2f3df | |||
| de08878de7 | |||
| f0e8da51d0 |
@@ -1771,7 +1771,7 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
"/health": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Check the API health status along with database connectivity details",
|
"description": "Check the API health status along with database connectivity and SMTP service details",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1768,7 +1768,7 @@
|
|||||||
},
|
},
|
||||||
"/health": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Check the API health status along with database connectivity details",
|
"description": "Check the API health status along with database connectivity and SMTP service details",
|
||||||
"consumes": [
|
"consumes": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1387,7 +1387,8 @@ paths:
|
|||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Check the API health status along with database connectivity details
|
description: Check the API health status along with database connectivity and
|
||||||
|
SMTP service details
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@@ -289,6 +289,19 @@ func setupTestContext(t *testing.T) *testContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupTestContextWithMiddleware(t *testing.T) *testContext {
|
||||||
|
t.Helper()
|
||||||
|
server := setupIntegrationTestServerWithMiddlewareEnabled(t)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
server.Cleanup()
|
||||||
|
})
|
||||||
|
return &testContext{
|
||||||
|
server: server,
|
||||||
|
client: server.NewHTTPClient(),
|
||||||
|
baseURL: server.BaseURL(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupTestContextWithAuthRateLimit(t *testing.T, authLimit int) *testContext {
|
func setupTestContextWithAuthRateLimit(t *testing.T, authLimit int) *testContext {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
server := setupIntegrationTestServerWithAuthRateLimit(t, authLimit)
|
server := setupIntegrationTestServerWithAuthRateLimit(t, authLimit)
|
||||||
@@ -603,14 +616,34 @@ func generateTokenWithExpiration(t *testing.T, user *database.User, cfg *config.
|
|||||||
|
|
||||||
type serverConfig struct {
|
type serverConfig struct {
|
||||||
authLimit int
|
authLimit int
|
||||||
|
disableCache bool
|
||||||
|
disableCompression bool
|
||||||
|
cacheablePaths []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupIntegrationTestServer(t *testing.T) *IntegrationTestServer {
|
func setupIntegrationTestServer(t *testing.T) *IntegrationTestServer {
|
||||||
return setupIntegrationTestServerWithConfig(t, serverConfig{authLimit: 50000})
|
return setupIntegrationTestServerWithConfig(t, serverConfig{
|
||||||
|
authLimit: 50000,
|
||||||
|
disableCache: true,
|
||||||
|
disableCompression: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupIntegrationTestServerWithAuthRateLimit(t *testing.T, authLimit int) *IntegrationTestServer {
|
func setupIntegrationTestServerWithAuthRateLimit(t *testing.T, authLimit int) *IntegrationTestServer {
|
||||||
return setupIntegrationTestServerWithConfig(t, serverConfig{authLimit: authLimit})
|
return setupIntegrationTestServerWithConfig(t, serverConfig{
|
||||||
|
authLimit: authLimit,
|
||||||
|
disableCache: true,
|
||||||
|
disableCompression: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupIntegrationTestServerWithMiddlewareEnabled(t *testing.T) *IntegrationTestServer {
|
||||||
|
return setupIntegrationTestServerWithConfig(t, serverConfig{
|
||||||
|
authLimit: 50000,
|
||||||
|
disableCache: false,
|
||||||
|
disableCompression: false,
|
||||||
|
cacheablePaths: []string{"/api/posts"},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupDatabase(t *testing.T) *gorm.DB {
|
func setupDatabase(t *testing.T) *gorm.DB {
|
||||||
@@ -678,7 +711,7 @@ func setupHandlers(authService handlers.AuthServiceInterface, userRepo repositor
|
|||||||
handlers.NewAPIHandler(cfg, postRepo, userRepo, voteService)
|
handlers.NewAPIHandler(cfg, postRepo, userRepo, voteService)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRouter(authHandler *handlers.AuthHandler, postHandler *handlers.PostHandler, voteHandler *handlers.VoteHandler, userHandler *handlers.UserHandler, apiHandler *handlers.APIHandler, authService handlers.AuthServiceInterface, cfg *config.Config) http.Handler {
|
func setupRouter(authHandler *handlers.AuthHandler, postHandler *handlers.PostHandler, voteHandler *handlers.VoteHandler, userHandler *handlers.UserHandler, apiHandler *handlers.APIHandler, authService handlers.AuthServiceInterface, cfg *config.Config, serverCfg serverConfig) http.Handler {
|
||||||
return server.NewRouter(server.RouterConfig{
|
return server.NewRouter(server.RouterConfig{
|
||||||
AuthHandler: authHandler,
|
AuthHandler: authHandler,
|
||||||
PostHandler: postHandler,
|
PostHandler: postHandler,
|
||||||
@@ -689,8 +722,9 @@ func setupRouter(authHandler *handlers.AuthHandler, postHandler *handlers.PostHa
|
|||||||
PageHandler: nil,
|
PageHandler: nil,
|
||||||
StaticDir: findWorkspaceRoot() + "/internal/static/",
|
StaticDir: findWorkspaceRoot() + "/internal/static/",
|
||||||
Debug: false,
|
Debug: false,
|
||||||
DisableCache: true,
|
DisableCache: serverCfg.disableCache,
|
||||||
DisableCompression: true,
|
DisableCompression: serverCfg.disableCompression,
|
||||||
|
CacheablePaths: serverCfg.cacheablePaths,
|
||||||
RateLimitConfig: cfg.RateLimit,
|
RateLimitConfig: cfg.RateLimit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -735,7 +769,7 @@ func setupIntegrationTestServerWithConfig(t *testing.T, serverCfg serverConfig)
|
|||||||
}
|
}
|
||||||
|
|
||||||
authHandler, postHandler, voteHandler, userHandler, apiHandler := setupHandlers(authService, userRepo, postRepo, voteService, cfg)
|
authHandler, postHandler, voteHandler, userHandler, apiHandler := setupHandlers(authService, userRepo, postRepo, voteService, cfg)
|
||||||
router := setupRouter(authHandler, postHandler, voteHandler, userHandler, apiHandler, authService, cfg)
|
router := setupRouter(authHandler, postHandler, voteHandler, userHandler, apiHandler, authService, cfg, serverCfg)
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"goyco/internal/testutils"
|
"goyco/internal/testutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestE2E_CompressionMiddleware(t *testing.T) {
|
func TestE2E_CompressionMiddleware(t *testing.T) {
|
||||||
ctx := setupTestContext(t)
|
ctx := setupTestContextWithMiddleware(t)
|
||||||
|
|
||||||
t.Run("compression_enabled_with_accept_encoding", func(t *testing.T) {
|
t.Run("compresses_response_when_accept_encoding_is_gzip", func(t *testing.T) {
|
||||||
request, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
request, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create request: %v", err)
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
@@ -27,15 +28,22 @@ func TestE2E_CompressionMiddleware(t *testing.T) {
|
|||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
failIfRateLimited(t, response.StatusCode, "compression enabled GET /api/posts")
|
||||||
|
|
||||||
contentEncoding := response.Header.Get("Content-Encoding")
|
contentEncoding := response.Header.Get("Content-Encoding")
|
||||||
if contentEncoding == "gzip" {
|
if contentEncoding != "gzip" {
|
||||||
|
t.Fatalf("Expected gzip compression, got Content-Encoding=%q", contentEncoding)
|
||||||
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(response.Body)
|
body, err := io.ReadAll(response.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to read response body: %v", err)
|
t.Fatalf("Failed to read response body: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isGzipCompressed(body) {
|
if !isGzipCompressed(body) {
|
||||||
|
t.Fatalf("Expected gzip-compressed body bytes")
|
||||||
|
}
|
||||||
|
|
||||||
reader, err := gzip.NewReader(bytes.NewReader(body))
|
reader, err := gzip.NewReader(bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||||
@@ -48,42 +56,44 @@ func TestE2E_CompressionMiddleware(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(decompressed) == 0 {
|
if len(decompressed) == 0 {
|
||||||
t.Error("Decompressed body is empty")
|
t.Fatal("Decompressed body is empty")
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Logf("Compression not applied (Content-Encoding: %s)", contentEncoding)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("no_compression_without_accept_encoding", func(t *testing.T) {
|
t.Run("does_not_compress_without_accept_encoding", func(t *testing.T) {
|
||||||
request, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
request, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create request: %v", err)
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
}
|
}
|
||||||
testutils.WithStandardHeaders(request)
|
testutils.WithStandardHeaders(request)
|
||||||
|
request.Header.Del("Accept-Encoding")
|
||||||
|
|
||||||
response, err := ctx.client.Do(request)
|
response, err := ctx.client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
failIfRateLimited(t, response.StatusCode, "compression disabled GET /api/posts")
|
||||||
|
|
||||||
contentEncoding := response.Header.Get("Content-Encoding")
|
contentEncoding := response.Header.Get("Content-Encoding")
|
||||||
if contentEncoding == "gzip" {
|
if contentEncoding == "gzip" {
|
||||||
t.Error("Expected no compression without Accept-Encoding header")
|
t.Fatal("Expected no compression without Accept-Encoding header")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("decompression_handles_gzip_request", func(t *testing.T) {
|
t.Run("accepts_valid_gzip_request_body", func(t *testing.T) {
|
||||||
testUser := ctx.createUserWithCleanup(t, "compressionuser", "StrongPass123!")
|
testUser := ctx.createUserWithCleanup(t, "compressionuser", "StrongPass123!")
|
||||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
gz := gzip.NewWriter(&buf)
|
gz := gzip.NewWriter(&buf)
|
||||||
postData := `{"title":"Compressed Post","url":"https://example.com/compressed","content":"Test content"}`
|
postData := `{"title":"Compressed Post","url":"https://example.com/compressed","content":"Test content"}`
|
||||||
gz.Write([]byte(postData))
|
if _, err := gz.Write([]byte(postData)); err != nil {
|
||||||
gz.Close()
|
t.Fatalf("Failed to gzip request body: %v", err)
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
t.Fatalf("Failed to finalize gzip body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", &buf)
|
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,20 +109,22 @@ func TestE2E_CompressionMiddleware(t *testing.T) {
|
|||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
failIfRateLimited(t, response.StatusCode, "decompression POST /api/posts")
|
||||||
|
|
||||||
switch response.StatusCode {
|
switch response.StatusCode {
|
||||||
case http.StatusBadRequest:
|
|
||||||
t.Log("Decompression middleware rejected invalid gzip")
|
|
||||||
case http.StatusCreated, http.StatusOK:
|
case http.StatusCreated, http.StatusOK:
|
||||||
t.Log("Decompression middleware handled gzip request successfully")
|
return
|
||||||
|
default:
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
t.Fatalf("Expected status %d or %d for valid gzip request, got %d. Body: %s", http.StatusCreated, http.StatusOK, response.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestE2E_CacheMiddleware(t *testing.T) {
|
func TestE2E_CacheMiddleware(t *testing.T) {
|
||||||
ctx := setupTestContext(t)
|
ctx := setupTestContextWithMiddleware(t)
|
||||||
|
|
||||||
t.Run("cache_miss_then_hit", func(t *testing.T) {
|
t.Run("returns_hit_after_repeated_get", func(t *testing.T) {
|
||||||
firstRequest, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
firstRequest, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create request: %v", err)
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
@@ -124,12 +136,14 @@ func TestE2E_CacheMiddleware(t *testing.T) {
|
|||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
firstResponse.Body.Close()
|
firstResponse.Body.Close()
|
||||||
|
failIfRateLimited(t, firstResponse.StatusCode, "first cache GET /api/posts")
|
||||||
|
|
||||||
firstCacheStatus := firstResponse.Header.Get("X-Cache")
|
firstCacheStatus := firstResponse.Header.Get("X-Cache")
|
||||||
if firstCacheStatus == "HIT" {
|
if firstCacheStatus == "HIT" {
|
||||||
t.Log("First request was cached (unexpected but acceptable)")
|
t.Fatalf("Expected first request to be a cache miss, got X-Cache=%q", firstCacheStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for range 8 {
|
||||||
secondRequest, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
secondRequest, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create request: %v", err)
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
@@ -140,18 +154,25 @@ func TestE2E_CacheMiddleware(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer secondResponse.Body.Close()
|
failIfRateLimited(t, secondResponse.StatusCode, "cache warmup GET /api/posts")
|
||||||
|
|
||||||
secondCacheStatus := secondResponse.Header.Get("X-Cache")
|
secondCacheStatus := secondResponse.Header.Get("X-Cache")
|
||||||
|
secondResponse.Body.Close()
|
||||||
|
|
||||||
if secondCacheStatus == "HIT" {
|
if secondCacheStatus == "HIT" {
|
||||||
t.Log("Second request was served from cache")
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatal("Expected a cache HIT on repeated requests, but none observed")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("cache_invalidation_on_post", func(t *testing.T) {
|
t.Run("invalidates_cached_get_after_post", func(t *testing.T) {
|
||||||
testUser := ctx.createUserWithCleanup(t, "cacheuser", "StrongPass123!")
|
testUser := ctx.createUserWithCleanup(t, "cacheuser", "StrongPass123!")
|
||||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||||
|
|
||||||
|
for attempt := range 8 {
|
||||||
firstRequest, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
firstRequest, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create request: %v", err)
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
@@ -163,8 +184,21 @@ func TestE2E_CacheMiddleware(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
|
failIfRateLimited(t, firstResponse.StatusCode, "cache priming GET /api/posts")
|
||||||
|
cacheStatus := firstResponse.Header.Get("X-Cache")
|
||||||
firstResponse.Body.Close()
|
firstResponse.Body.Close()
|
||||||
|
|
||||||
|
if cacheStatus == "HIT" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt == 7 {
|
||||||
|
t.Fatal("Failed to prime cache: repeated GET requests never produced X-Cache=HIT")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
postData := `{"title":"Cache Invalidation Test","url":"https://example.com/cache","content":"Test"}`
|
postData := `{"title":"Cache Invalidation Test","url":"https://example.com/cache","content":"Test"}`
|
||||||
secondRequest, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", strings.NewReader(postData))
|
secondRequest, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", strings.NewReader(postData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,8 +212,15 @@ func TestE2E_CacheMiddleware(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
|
failIfRateLimited(t, secondResponse.StatusCode, "cache invalidation POST /api/posts")
|
||||||
|
if secondResponse.StatusCode != http.StatusCreated && secondResponse.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(secondResponse.Body)
|
||||||
|
secondResponse.Body.Close()
|
||||||
|
t.Fatalf("Expected post creation status %d or %d, got %d. Body: %s", http.StatusCreated, http.StatusOK, secondResponse.StatusCode, string(body))
|
||||||
|
}
|
||||||
secondResponse.Body.Close()
|
secondResponse.Body.Close()
|
||||||
|
|
||||||
|
for range 8 {
|
||||||
thirdRequest, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
thirdRequest, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create request: %v", err)
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
@@ -191,19 +232,25 @@ func TestE2E_CacheMiddleware(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer thirdResponse.Body.Close()
|
failIfRateLimited(t, thirdResponse.StatusCode, "post-invalidation GET /api/posts")
|
||||||
|
|
||||||
cacheStatus := thirdResponse.Header.Get("X-Cache")
|
cacheStatus := thirdResponse.Header.Get("X-Cache")
|
||||||
if cacheStatus == "HIT" {
|
thirdResponse.Body.Close()
|
||||||
t.Log("Cache was invalidated after POST")
|
|
||||||
|
if cacheStatus != "HIT" {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatal("Expected cache to be invalidated after POST, but X-Cache stayed HIT")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestE2E_CSRFProtection(t *testing.T) {
|
func TestE2E_CSRFProtection(t *testing.T) {
|
||||||
ctx := setupTestContext(t)
|
ctx := setupTestContext(t)
|
||||||
|
|
||||||
t.Run("csrf_protection_for_non_api_routes", func(t *testing.T) {
|
t.Run("non_api_post_without_csrf_is_forbidden_or_unmounted", func(t *testing.T) {
|
||||||
request, err := http.NewRequest("POST", ctx.baseURL+"/auth/login", strings.NewReader(`{"username":"test","password":"test"}`))
|
request, err := http.NewRequest("POST", ctx.baseURL+"/auth/login", strings.NewReader(`{"username":"test","password":"test"}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create request: %v", err)
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
@@ -216,15 +263,15 @@ func TestE2E_CSRFProtection(t *testing.T) {
|
|||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
failIfRateLimited(t, response.StatusCode, "CSRF non-API POST /auth/login")
|
||||||
|
|
||||||
if response.StatusCode == http.StatusForbidden {
|
if response.StatusCode != http.StatusForbidden && response.StatusCode != http.StatusNotFound {
|
||||||
t.Log("CSRF protection active for non-API routes")
|
body, _ := io.ReadAll(response.Body)
|
||||||
} else {
|
t.Fatalf("Expected status %d (CSRF protected) or %d (route unavailable in test setup), got %d. Body: %s", http.StatusForbidden, http.StatusNotFound, response.StatusCode, string(body))
|
||||||
t.Logf("CSRF check result: status %d", response.StatusCode)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("csrf_bypass_for_api_routes", func(t *testing.T) {
|
t.Run("api_post_without_csrf_is_not_forbidden", func(t *testing.T) {
|
||||||
testUser := ctx.createUserWithCleanup(t, "csrfuser", "StrongPass123!")
|
testUser := ctx.createUserWithCleanup(t, "csrfuser", "StrongPass123!")
|
||||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||||
|
|
||||||
@@ -242,13 +289,15 @@ func TestE2E_CSRFProtection(t *testing.T) {
|
|||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
failIfRateLimited(t, response.StatusCode, "CSRF bypass POST /api/posts")
|
||||||
|
|
||||||
if response.StatusCode == http.StatusForbidden {
|
if response.StatusCode == http.StatusForbidden {
|
||||||
t.Error("API routes should bypass CSRF protection")
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
t.Fatalf("API routes should bypass CSRF protection, got 403. Body: %s", string(body))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("csrf_allows_get_requests", func(t *testing.T) {
|
t.Run("get_request_without_csrf_is_not_forbidden", func(t *testing.T) {
|
||||||
request, err := http.NewRequest("GET", ctx.baseURL+"/auth/login", nil)
|
request, err := http.NewRequest("GET", ctx.baseURL+"/auth/login", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create request: %v", err)
|
t.Fatalf("Failed to create request: %v", err)
|
||||||
@@ -260,9 +309,10 @@ func TestE2E_CSRFProtection(t *testing.T) {
|
|||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
failIfRateLimited(t, response.StatusCode, "CSRF GET /auth/login")
|
||||||
|
|
||||||
if response.StatusCode == http.StatusForbidden {
|
if response.StatusCode == http.StatusForbidden {
|
||||||
t.Error("GET requests should not require CSRF token")
|
t.Fatal("GET requests should not require CSRF token")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -270,7 +320,7 @@ func TestE2E_CSRFProtection(t *testing.T) {
|
|||||||
func TestE2E_RequestSizeLimit(t *testing.T) {
|
func TestE2E_RequestSizeLimit(t *testing.T) {
|
||||||
ctx := setupTestContext(t)
|
ctx := setupTestContext(t)
|
||||||
|
|
||||||
t.Run("request_within_size_limit", func(t *testing.T) {
|
t.Run("accepts_request_within_size_limit", func(t *testing.T) {
|
||||||
testUser := ctx.createUserWithCleanup(t, "sizelimituser", "StrongPass123!")
|
testUser := ctx.createUserWithCleanup(t, "sizelimituser", "StrongPass123!")
|
||||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||||
|
|
||||||
@@ -289,13 +339,15 @@ func TestE2E_RequestSizeLimit(t *testing.T) {
|
|||||||
t.Fatalf("Request failed: %v", err)
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
failIfRateLimited(t, response.StatusCode, "request within size limit POST /api/posts")
|
||||||
|
|
||||||
if response.StatusCode == http.StatusRequestEntityTooLarge {
|
if response.StatusCode == http.StatusRequestEntityTooLarge {
|
||||||
t.Error("Small request should not exceed size limit")
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
t.Fatalf("Small request should not exceed size limit. Body: %s", string(body))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("request_exceeds_size_limit", func(t *testing.T) {
|
t.Run("rejects_or_fails_oversized_request", func(t *testing.T) {
|
||||||
testUser := ctx.createUserWithCleanup(t, "sizelimituser2", "StrongPass123!")
|
testUser := ctx.createUserWithCleanup(t, "sizelimituser2", "StrongPass123!")
|
||||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||||
|
|
||||||
@@ -311,14 +363,14 @@ func TestE2E_RequestSizeLimit(t *testing.T) {
|
|||||||
|
|
||||||
response, err := ctx.client.Do(request)
|
response, err := ctx.client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
t.Fatalf("Request failed: %v", err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
failIfRateLimited(t, response.StatusCode, "request exceeds size limit POST /api/posts")
|
||||||
|
|
||||||
if response.StatusCode == http.StatusRequestEntityTooLarge {
|
if response.StatusCode != http.StatusRequestEntityTooLarge && response.StatusCode != http.StatusBadRequest {
|
||||||
t.Log("Request size limit enforced correctly")
|
body, _ := io.ReadAll(response.Body)
|
||||||
} else {
|
t.Fatalf("Expected status %d or %d for oversized request, got %d. Body: %s", http.StatusRequestEntityTooLarge, http.StatusBadRequest, response.StatusCode, string(body))
|
||||||
t.Logf("Request size limit check result: status %d", response.StatusCode)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package integration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -63,13 +62,33 @@ func TestIntegration_Router_FullMiddlewareChain(t *testing.T) {
|
|||||||
t.Run("DBMonitoring_Active", func(t *testing.T) {
|
t.Run("DBMonitoring_Active", func(t *testing.T) {
|
||||||
request := makeGetRequest(t, router, "/health")
|
request := makeGetRequest(t, router, "/health")
|
||||||
|
|
||||||
var response map[string]any
|
response := assertJSONResponse(t, request, http.StatusOK)
|
||||||
if err := json.NewDecoder(request.Body).Decode(&response); err == nil {
|
if response == nil {
|
||||||
if data, ok := response["data"].(map[string]any); ok {
|
return
|
||||||
if _, exists := data["database_stats"]; !exists {
|
|
||||||
t.Error("Expected database_stats in health response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data, ok := getDataFromResponse(response)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Expected data to be a map")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
services, ok := data["services"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Expected services in health response")
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseService, ok := services["database"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Expected database service in health response")
|
||||||
|
}
|
||||||
|
|
||||||
|
details, ok := databaseService["details"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Expected database details in health response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := details["stats"]; !exists {
|
||||||
|
t.Error("Expected database stats in health response")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,7 @@ func NewCORSConfig() *CORSConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch env {
|
switch env {
|
||||||
case "production":
|
case "production", "staging":
|
||||||
if origins := os.Getenv("CORS_ALLOWED_ORIGINS"); origins == "" {
|
|
||||||
config.AllowedOrigins = []string{}
|
|
||||||
}
|
|
||||||
config.AllowCredentials = true
|
|
||||||
case "staging":
|
|
||||||
if origins := os.Getenv("CORS_ALLOWED_ORIGINS"); origins == "" {
|
if origins := os.Getenv("CORS_ALLOWED_ORIGINS"); origins == "" {
|
||||||
config.AllowedOrigins = []string{}
|
config.AllowedOrigins = []string{}
|
||||||
}
|
}
|
||||||
@@ -53,82 +48,66 @@ func NewCORSConfig() *CORSConfig {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkOrigin(origin string, allowedOrigins []string) (allowed bool, hasWildcard bool) {
|
||||||
|
for _, allowedOrigin := range allowedOrigins {
|
||||||
|
if allowedOrigin == "*" {
|
||||||
|
hasWildcard = true
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if allowedOrigin == origin {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCORSHeaders(w http.ResponseWriter, origin string, hasWildcard bool, config *CORSConfig) {
|
||||||
|
if hasWildcard && !config.AllowCredentials {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.AllowCredentials && !hasWildcard {
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func CORSWithConfig(config *CORSConfig) func(http.Handler) http.Handler {
|
func CORSWithConfig(config *CORSConfig) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
|
|
||||||
|
if origin == "" {
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == "OPTIONS" {
|
||||||
if origin != "" {
|
w.WriteHeader(http.StatusOK)
|
||||||
allowed := false
|
return
|
||||||
hasWildcard := false
|
|
||||||
for _, allowedOrigin := range config.AllowedOrigins {
|
|
||||||
if allowedOrigin == "*" {
|
|
||||||
hasWildcard = true
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if allowedOrigin == origin {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowed, hasWildcard := checkOrigin(origin, config.AllowedOrigins)
|
||||||
|
|
||||||
if !allowed {
|
if !allowed {
|
||||||
http.Error(w, "Origin not allowed", http.StatusForbidden)
|
http.Error(w, "Origin not allowed", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasWildcard && !config.AllowCredentials {
|
if r.Method == "OPTIONS" {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
setCORSHeaders(w, origin, hasWildcard, config)
|
||||||
} else {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
|
w.Header().Set("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
|
||||||
w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
|
w.Header().Set("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
|
||||||
w.Header().Set("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
|
w.Header().Set("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
|
||||||
|
|
||||||
if config.AllowCredentials && !hasWildcard {
|
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if origin != "" {
|
setCORSHeaders(w, origin, hasWildcard, config)
|
||||||
allowed := false
|
|
||||||
hasWildcard := false
|
|
||||||
for _, allowedOrigin := range config.AllowedOrigins {
|
|
||||||
if allowedOrigin == "*" {
|
|
||||||
hasWildcard = true
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if allowedOrigin == origin {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allowed {
|
|
||||||
http.Error(w, "Origin not allowed", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasWildcard && !config.AllowCredentials {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
} else {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.AllowCredentials && !hasWildcard {
|
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type RouterConfig struct {
|
|||||||
Debug bool
|
Debug bool
|
||||||
DisableCache bool
|
DisableCache bool
|
||||||
DisableCompression bool
|
DisableCompression bool
|
||||||
|
CacheablePaths []string
|
||||||
DBMonitor middleware.DBMonitor
|
DBMonitor middleware.DBMonitor
|
||||||
RateLimitConfig config.RateLimitConfig
|
RateLimitConfig config.RateLimitConfig
|
||||||
}
|
}
|
||||||
@@ -49,6 +50,9 @@ func NewRouter(cfg RouterConfig) http.Handler {
|
|||||||
if !cfg.DisableCache {
|
if !cfg.DisableCache {
|
||||||
cache := middleware.NewInMemoryCache()
|
cache := middleware.NewInMemoryCache()
|
||||||
cacheConfig := middleware.DefaultCacheConfig()
|
cacheConfig := middleware.DefaultCacheConfig()
|
||||||
|
if len(cfg.CacheablePaths) > 0 {
|
||||||
|
cacheConfig.CacheablePaths = append([]string{}, cfg.CacheablePaths...)
|
||||||
|
}
|
||||||
router.Use(middleware.CacheMiddleware(cache, cacheConfig))
|
router.Use(middleware.CacheMiddleware(cache, cacheConfig))
|
||||||
router.Use(middleware.CacheInvalidationMiddleware(cache))
|
router.Use(middleware.CacheInvalidationMiddleware(cache))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# screenshot
|
||||||
|
|
||||||
In this folder, you will find screenshots of the app.
|
In this folder, you will find screenshots of the app.
|
||||||
|
|
||||||
Two kinds of screenshot here:
|
Two kinds of screenshot here:
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ if [ "$EUID" -ne 0 ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
read -s "Do you want to install PostgreSQL 18? [y/N] " INSTALL_PG
|
read -rp "Do you want to install PostgreSQL 18? [y/N] " INSTALL_PG
|
||||||
if [ "$INSTALL_PG" != "y" ]; then
|
if [ "$INSTALL_PG" != "y" ]; then
|
||||||
echo "PostgreSQL 18 will not be installed"
|
echo "PostgreSQL 18 will not be installed"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
read -s -p "Enter password for PostgreSQL user 'goyco': " GOYCO_PWD
|
read -rsp "Enter password for PostgreSQL user 'goyco': " GOYCO_PWD
|
||||||
echo
|
echo
|
||||||
|
|
||||||
apt-get update
|
apt-get update
|
||||||
|
|||||||
Reference in New Issue
Block a user