910 lines
27 KiB
Go
910 lines
27 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"goyco/internal/config"
|
|
"goyco/internal/database"
|
|
"goyco/internal/handlers"
|
|
"goyco/internal/middleware"
|
|
"goyco/internal/repositories"
|
|
"goyco/internal/server"
|
|
"goyco/internal/services"
|
|
"goyco/internal/testutils"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
type (
|
|
APIResponse = testutils.APIResponse
|
|
LoginResponse = testutils.LoginResponse
|
|
PostResponse = testutils.PostResponse
|
|
PostsListResponse = testutils.PostsListResponse
|
|
Post = testutils.Post
|
|
VoteResponse = testutils.VoteResponse
|
|
TestUser = testutils.TestUser
|
|
TestPost = testutils.TestPost
|
|
HealthResponse = testutils.HealthResponse
|
|
MetricsResponse = testutils.MetricsResponse
|
|
UserResponse = testutils.UserResponse
|
|
ProfileResponse = testutils.ProfileResponse
|
|
AccountDeletionResponse = testutils.AccountDeletionResponse
|
|
)
|
|
|
|
type AuthenticatedClient struct {
|
|
*testutils.AuthenticatedClient
|
|
}
|
|
|
|
type testContext struct {
|
|
server *IntegrationTestServer
|
|
client *http.Client
|
|
baseURL string
|
|
}
|
|
|
|
type IntegrationTestServer struct {
|
|
DB *gorm.DB
|
|
Server *httptest.Server
|
|
baseURL string
|
|
transport http.RoundTripper
|
|
closeFunc func()
|
|
UserRepo repositories.UserRepository
|
|
PostRepo repositories.PostRepository
|
|
VoteRepo repositories.VoteRepository
|
|
RefreshTokenRepo *repositories.RefreshTokenRepository
|
|
AuthService handlers.AuthServiceInterface
|
|
VoteService *services.VoteService
|
|
AuthHandler *handlers.AuthHandler
|
|
PostHandler *handlers.PostHandler
|
|
VoteHandler *handlers.VoteHandler
|
|
UserHandler *handlers.UserHandler
|
|
APIHandler *handlers.APIHandler
|
|
EmailSender *testutils.MockEmailSender
|
|
}
|
|
|
|
func (server *IntegrationTestServer) BaseURL() string {
|
|
return server.baseURL
|
|
}
|
|
|
|
func (server *IntegrationTestServer) NewHTTPClient() *http.Client {
|
|
return &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: server.transport,
|
|
}
|
|
}
|
|
|
|
func (server *IntegrationTestServer) Cleanup() {
|
|
if server.closeFunc != nil {
|
|
server.closeFunc()
|
|
}
|
|
if server.DB != nil {
|
|
sqlDB, err := server.DB.DB()
|
|
if err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
type inMemoryRoundTripper struct {
|
|
handler http.Handler
|
|
}
|
|
|
|
func newInMemoryRoundTripper(handler http.Handler) http.RoundTripper {
|
|
return &inMemoryRoundTripper{handler: handler}
|
|
}
|
|
|
|
func (rt *inMemoryRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
if rt == nil || rt.handler == nil {
|
|
return nil, fmt.Errorf("in-memory round tripper not initialized")
|
|
}
|
|
|
|
var bodyBytes []byte
|
|
if request.Body != nil && request.Body != http.NoBody {
|
|
defer request.Body.Close()
|
|
var err error
|
|
bodyBytes, err = io.ReadAll(request.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read request body: %w", err)
|
|
}
|
|
}
|
|
|
|
if len(bodyBytes) > 0 {
|
|
request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|
} else {
|
|
request.Body = http.NoBody
|
|
}
|
|
|
|
clonedRequest := request.Clone(request.Context())
|
|
if len(bodyBytes) > 0 {
|
|
clonedRequest.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|
} else {
|
|
clonedRequest.Body = http.NoBody
|
|
}
|
|
clonedRequest.RequestURI = clonedRequest.URL.RequestURI()
|
|
|
|
recorder := httptest.NewRecorder()
|
|
rt.handler.ServeHTTP(recorder, clonedRequest)
|
|
resp := recorder.Result()
|
|
return resp, nil
|
|
}
|
|
|
|
var (
|
|
sanitizeIdentifierRegex = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
|
|
)
|
|
|
|
func findWorkspaceRoot() string {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return "."
|
|
}
|
|
root := wd
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(root, "go.mod")); err == nil {
|
|
return root
|
|
}
|
|
parent := filepath.Dir(root)
|
|
if parent == root {
|
|
return wd
|
|
}
|
|
root = parent
|
|
}
|
|
}
|
|
|
|
func sanitizeIdentifier(value string) string {
|
|
sanitized := sanitizeIdentifierRegex.ReplaceAllString(value, "_")
|
|
sanitized = strings.Trim(sanitized, "_")
|
|
if sanitized == "" {
|
|
fallback := fmt.Sprintf("test_%d", time.Now().UnixNano())
|
|
sanitized = sanitizeIdentifierRegex.ReplaceAllString(fallback, "_")
|
|
sanitized = strings.Trim(sanitized, "_")
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
func uniqueTestID(t *testing.T) string {
|
|
rawID := fmt.Sprintf("%s_%d", t.Name(), time.Now().UnixNano())
|
|
return sanitizeIdentifier(rawID)
|
|
}
|
|
|
|
func getTestFilePrefix(t *testing.T) string {
|
|
testName := t.Name()
|
|
parts := strings.Split(testName, "/")
|
|
if len(parts) > 0 {
|
|
filePart := parts[len(parts)-1]
|
|
if idx := strings.Index(filePart, "_"); idx > 0 {
|
|
return filePart[:idx]
|
|
}
|
|
return strings.ToLower(strings.TrimPrefix(filePart, "TestE2E_"))
|
|
}
|
|
return "test"
|
|
}
|
|
|
|
func uniqueUsername(t *testing.T, prefix string) string {
|
|
filePrefix := getTestFilePrefix(t)
|
|
fullPrefix := fmt.Sprintf("%s_%s", filePrefix, prefix)
|
|
username := fmt.Sprintf("%s_%s", fullPrefix, uniqueTestID(t))
|
|
if len(username) > 50 {
|
|
maxIDLength := max(50-len(fullPrefix)-1, 0)
|
|
testID := uniqueTestID(t)
|
|
if len(testID) > maxIDLength {
|
|
testID = testID[:maxIDLength]
|
|
}
|
|
username = fmt.Sprintf("%s_%s", fullPrefix, testID)
|
|
if len(username) > 50 {
|
|
username = username[:50]
|
|
}
|
|
}
|
|
return username
|
|
}
|
|
|
|
func uniqueEmail(t *testing.T, prefix string) string {
|
|
return fmt.Sprintf("%s_%s@example.com", prefix, uniqueTestID(t))
|
|
}
|
|
|
|
type TestFixtures struct {
|
|
VerifiedUser *TestUser
|
|
UnverifiedUser *TestUser
|
|
LockedUser *TestUser
|
|
PostWithVotes *TestPost
|
|
PostNoVotes *TestPost
|
|
}
|
|
|
|
func getResponseReader(resp *http.Response) (io.Reader, func(), error) {
|
|
var reader io.Reader = resp.Body
|
|
var cleanup func() = func() {}
|
|
|
|
if resp.Header.Get("Content-Encoding") == "gzip" {
|
|
gzReader, err := gzip.NewReader(resp.Body)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
|
}
|
|
reader = gzReader
|
|
cleanup = func() { _ = gzReader.Close() }
|
|
}
|
|
return reader, cleanup, nil
|
|
}
|
|
|
|
func tokenHash(token string) string {
|
|
hash := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
func retryOnRateLimit(t *testing.T, maxRetries int, operation func() int) int {
|
|
t.Helper()
|
|
for attempt := range maxRetries {
|
|
statusCode := operation()
|
|
if statusCode != http.StatusTooManyRequests {
|
|
return statusCode
|
|
}
|
|
if attempt < maxRetries-1 {
|
|
backoff := time.Duration(attempt+1) * 50 * time.Millisecond
|
|
time.Sleep(backoff)
|
|
}
|
|
}
|
|
return http.StatusTooManyRequests
|
|
}
|
|
|
|
func skipIfRateLimited(t *testing.T, statusCode int, reason string) {
|
|
if statusCode == http.StatusTooManyRequests {
|
|
t.Skipf("Skipping %s: rate limited", reason)
|
|
}
|
|
}
|
|
|
|
func setupTestContext(t *testing.T) *testContext {
|
|
t.Helper()
|
|
server := setupIntegrationTestServer(t)
|
|
t.Cleanup(func() {
|
|
server.Cleanup()
|
|
})
|
|
return &testContext{
|
|
server: server,
|
|
client: server.NewHTTPClient(),
|
|
baseURL: server.BaseURL(),
|
|
}
|
|
}
|
|
|
|
func setupTestContextWithAuthRateLimit(t *testing.T, authLimit int) *testContext {
|
|
t.Helper()
|
|
server := setupIntegrationTestServerWithAuthRateLimit(t, authLimit)
|
|
t.Cleanup(func() {
|
|
server.Cleanup()
|
|
})
|
|
return &testContext{
|
|
server: server,
|
|
client: server.NewHTTPClient(),
|
|
baseURL: server.BaseURL(),
|
|
}
|
|
}
|
|
|
|
func (ctx *testContext) createUserWithCleanup(t *testing.T, prefix, password string) *TestUser {
|
|
t.Helper()
|
|
if password == "" {
|
|
password = "Password123!"
|
|
}
|
|
user := testutils.CreateE2ETestUser(t, ctx.server.UserRepo, uniqueUsername(t, prefix), uniqueEmail(t, prefix), password)
|
|
t.Cleanup(func() {
|
|
if user != nil {
|
|
ctx.server.UserRepo.Delete(user.ID)
|
|
}
|
|
})
|
|
return user
|
|
}
|
|
|
|
func (ctx *testContext) createMultipleUsersWithCleanup(t *testing.T, count int, prefix, password string) []*TestUser {
|
|
t.Helper()
|
|
if password == "" {
|
|
password = "Password123!"
|
|
}
|
|
var users []*TestUser
|
|
for i := range count {
|
|
userPrefix := fmt.Sprintf("%s%d", prefix, i+1)
|
|
user := testutils.CreateE2ETestUser(t, ctx.server.UserRepo, uniqueUsername(t, userPrefix), uniqueEmail(t, userPrefix), password)
|
|
users = append(users, user)
|
|
}
|
|
t.Cleanup(func() {
|
|
for _, user := range users {
|
|
if user != nil {
|
|
ctx.server.UserRepo.Delete(user.ID)
|
|
}
|
|
}
|
|
})
|
|
return users
|
|
}
|
|
|
|
func (ctx *testContext) createUserAndLogin(t *testing.T, prefix, password string) (*TestUser, *AuthenticatedClient) {
|
|
t.Helper()
|
|
user := ctx.createUserWithCleanup(t, prefix, password)
|
|
client := ctx.loginUser(t, user.Username, user.Password)
|
|
return user, client
|
|
}
|
|
|
|
func (ctx *testContext) doLoginRequest(t *testing.T, username, password, ipAddress string) (*http.Response, error) {
|
|
t.Helper()
|
|
loginData := map[string]string{
|
|
"username": username,
|
|
"password": password,
|
|
}
|
|
body, err := json.Marshal(loginData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal login data: %w", err)
|
|
}
|
|
req, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create login request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
testutils.WithStandardHeaders(req)
|
|
req.Header.Set("X-Forwarded-For", ipAddress)
|
|
return ctx.client.Do(req)
|
|
}
|
|
|
|
func (ctx *testContext) doRequest(t *testing.T, method, path string, body io.Reader, headers map[string]string) (*http.Response, error) {
|
|
t.Helper()
|
|
builder := testutils.NewRequestBuilder(method, ctx.baseURL+path)
|
|
if body != nil {
|
|
builder = builder.WithBody(body)
|
|
}
|
|
for k, v := range headers {
|
|
builder = builder.WithHeader(k, v)
|
|
}
|
|
req, err := builder.Build()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ctx.client.Do(req)
|
|
}
|
|
|
|
func (ctx *testContext) doRequestExpectStatus(t *testing.T, method, path string, expectedStatus int, body io.Reader, headers map[string]string) int {
|
|
t.Helper()
|
|
resp, err := ctx.doRequest(t, method, path, body, headers)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != expectedStatus && resp.StatusCode != http.StatusTooManyRequests {
|
|
bodyBytes := make([]byte, 1024)
|
|
n, _ := resp.Body.Read(bodyBytes)
|
|
t.Fatalf("expected status %d (or 429), got %d. Body: %s", expectedStatus, resp.StatusCode, string(bodyBytes[:n]))
|
|
}
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func decodeJSONResponse(t *testing.T, resp *http.Response, target any) {
|
|
t.Helper()
|
|
reader, cleanup, err := getResponseReader(resp)
|
|
if err != nil {
|
|
t.Fatalf("failed to get response reader: %v", err)
|
|
}
|
|
defer cleanup()
|
|
if err := json.NewDecoder(reader).Decode(target); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
}
|
|
|
|
func (ctx *testContext) loginUser(t *testing.T, username, password string) *AuthenticatedClient {
|
|
t.Helper()
|
|
return ctx.loginUserWithIP(t, username, password, testutils.GenerateTestIP())
|
|
}
|
|
|
|
func (ctx *testContext) loginUserWithIP(t *testing.T, username, password, ipAddress string) *AuthenticatedClient {
|
|
t.Helper()
|
|
resp, err := ctx.doLoginRequest(t, username, password, ipAddress)
|
|
if err != nil {
|
|
t.Fatalf("login request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes := make([]byte, 1024)
|
|
n, _ := resp.Body.Read(bodyBytes)
|
|
t.Fatalf("login failed with status %d. Response: %s", resp.StatusCode, string(bodyBytes[:n]))
|
|
}
|
|
|
|
var loginResp testutils.LoginResponse
|
|
decodeJSONResponse(t, resp, &loginResp)
|
|
|
|
if !loginResp.Success {
|
|
t.Fatalf("login response indicates failure: %s", loginResp.Message)
|
|
}
|
|
|
|
accessToken := loginResp.Data.AccessToken
|
|
if accessToken == "" {
|
|
accessToken = loginResp.Data.Token
|
|
}
|
|
if accessToken == "" {
|
|
t.Fatalf("login response missing access token")
|
|
}
|
|
|
|
return &AuthenticatedClient{
|
|
AuthenticatedClient: &testutils.AuthenticatedClient{
|
|
Client: ctx.client,
|
|
Token: accessToken,
|
|
RefreshToken: loginResp.Data.RefreshToken,
|
|
BaseURL: ctx.baseURL,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (ctx *testContext) loginUserSafe(t *testing.T, username, password string) (*AuthenticatedClient, error) {
|
|
t.Helper()
|
|
authClient, err := testutils.LoginUserSafe(ctx.client, ctx.baseURL, username, password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &AuthenticatedClient{AuthenticatedClient: authClient}, nil
|
|
}
|
|
|
|
func (ctx *testContext) loginExpectStatus(t *testing.T, username, password string, expectedStatus int) int {
|
|
t.Helper()
|
|
return ctx.loginExpectStatusWithIP(t, username, password, expectedStatus, testutils.GenerateTestIP())
|
|
}
|
|
|
|
func (ctx *testContext) loginExpectStatusWithIP(t *testing.T, username, password string, expectedStatus int, ipAddress string) int {
|
|
t.Helper()
|
|
resp, err := ctx.doLoginRequest(t, username, password, ipAddress)
|
|
if err != nil {
|
|
t.Fatalf("login request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != expectedStatus && resp.StatusCode != http.StatusTooManyRequests {
|
|
bodyBytes := make([]byte, 1024)
|
|
n, _ := resp.Body.Read(bodyBytes)
|
|
t.Fatalf("expected login status %d (or 429), got %d. Body: %s", expectedStatus, resp.StatusCode, string(bodyBytes[:n]))
|
|
}
|
|
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func (ctx *testContext) confirmEmail(t *testing.T, token string) {
|
|
t.Helper()
|
|
if token == "" {
|
|
t.Fatalf("confirmation token must not be empty")
|
|
}
|
|
confirmURL := fmt.Sprintf("%s/api/auth/confirm?token=%s", ctx.baseURL, url.QueryEscape(token))
|
|
req, err := http.NewRequest("GET", confirmURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("failed to build confirmation request: %v", err)
|
|
}
|
|
testutils.WithStandardHeaders(req)
|
|
resp, err := ctx.client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("confirmation request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("expected confirmation to return 200, got %d. Body: %s", resp.StatusCode, string(body))
|
|
}
|
|
}
|
|
|
|
func (ctx *testContext) registerUserExpectStatus(t *testing.T, username, email, password string) int {
|
|
t.Helper()
|
|
registerData := map[string]string{
|
|
"username": username,
|
|
"email": email,
|
|
"password": password,
|
|
}
|
|
body, err := json.Marshal(registerData)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal register data: %v", err)
|
|
}
|
|
headers := map[string]string{"Content-Type": "application/json"}
|
|
return ctx.doRequestExpectStatus(t, "POST", "/api/auth/register", http.StatusCreated, bytes.NewReader(body), headers)
|
|
}
|
|
|
|
func (ctx *testContext) makeRequestWithToken(t *testing.T, token string) int {
|
|
t.Helper()
|
|
headers := make(map[string]string)
|
|
if token != "" {
|
|
headers["Authorization"] = "Bearer " + token
|
|
}
|
|
resp, err := ctx.doRequest(t, "GET", "/api/auth/me", nil, headers)
|
|
if err != nil {
|
|
t.Fatalf("failed to make request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func (ctx *testContext) requestAccountDeletionExpectStatus(t *testing.T, token string, expectedStatus int) (int, *AccountDeletionResponse) {
|
|
t.Helper()
|
|
headers := map[string]string{"Authorization": "Bearer " + token}
|
|
resp, err := ctx.doRequest(t, "DELETE", "/api/auth/account", nil, headers)
|
|
if err != nil {
|
|
t.Fatalf("failed to make account deletion request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != expectedStatus && resp.StatusCode != http.StatusTooManyRequests {
|
|
bodyBytes := make([]byte, 1024)
|
|
n, _ := resp.Body.Read(bodyBytes)
|
|
t.Fatalf("expected account deletion status %d (or 429), got %d. Response: %s", expectedStatus, resp.StatusCode, string(bodyBytes[:n]))
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
var deletionResponse AccountDeletionResponse
|
|
decodeJSONResponse(t, resp, &deletionResponse)
|
|
return resp.StatusCode, &deletionResponse
|
|
}
|
|
|
|
return resp.StatusCode, nil
|
|
}
|
|
|
|
func generateTestToken(t *testing.T, user *database.User, cfg *config.JWTConfig, opts ...func(*testutils.TokenClaims, *config.JWTConfig) string) string {
|
|
var secret string
|
|
return testutils.GenerateTestToken(t, user, cfg, func(claims *testutils.TokenClaims, jwtCfg *config.JWTConfig) string {
|
|
secret = jwtCfg.Secret
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
secret = opt(claims, jwtCfg)
|
|
}
|
|
}
|
|
return secret
|
|
})
|
|
}
|
|
|
|
func generateExpiredToken(t *testing.T, user *database.User, cfg *config.JWTConfig) string {
|
|
return generateTestToken(t, user, cfg, testutils.WithExpiredToken)
|
|
}
|
|
|
|
func generateTamperedToken(t *testing.T, user *database.User, cfg *config.JWTConfig) string {
|
|
return generateTestToken(t, user, cfg, testutils.WithTamperedSecret)
|
|
}
|
|
|
|
func generateTokenWithSessionVersion(t *testing.T, user *database.User, cfg *config.JWTConfig, sessionVersion uint) string {
|
|
return generateTestToken(t, user, cfg, func(claims *testutils.TokenClaims, jwtCfg *config.JWTConfig) string {
|
|
claims.SessionVersion = sessionVersion
|
|
return jwtCfg.Secret
|
|
})
|
|
}
|
|
|
|
func generateTokenWithType(t *testing.T, user *database.User, cfg *config.JWTConfig, tokenType string) string {
|
|
return generateTestToken(t, user, cfg, func(claims *testutils.TokenClaims, jwtCfg *config.JWTConfig) string {
|
|
claims.TokenType = tokenType
|
|
return jwtCfg.Secret
|
|
})
|
|
}
|
|
|
|
func generateTokenWithExpiration(t *testing.T, user *database.User, cfg *config.JWTConfig, expiration time.Duration) string {
|
|
return generateTestToken(t, user, cfg, func(claims *testutils.TokenClaims, jwtCfg *config.JWTConfig) string {
|
|
now := time.Now()
|
|
claims.IssuedAt = jwt.NewNumericDate(now)
|
|
claims.ExpiresAt = jwt.NewNumericDate(now.Add(expiration))
|
|
return jwtCfg.Secret
|
|
})
|
|
}
|
|
|
|
type serverConfig struct {
|
|
authLimit int
|
|
}
|
|
|
|
func setupIntegrationTestServer(t *testing.T) *IntegrationTestServer {
|
|
return setupIntegrationTestServerWithConfig(t, serverConfig{authLimit: 50000})
|
|
}
|
|
|
|
func setupIntegrationTestServerWithAuthRateLimit(t *testing.T, authLimit int) *IntegrationTestServer {
|
|
return setupIntegrationTestServerWithConfig(t, serverConfig{authLimit: authLimit})
|
|
}
|
|
|
|
func setupDatabase(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
uniqueID := fmt.Sprintf("%s_%d", sanitizeIdentifier(t.Name()), time.Now().UnixNano())
|
|
dbName := "file:memdb_" + uniqueID + "?mode=memory&cache=shared&_journal_mode=WAL&_synchronous=NORMAL"
|
|
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to connect to in-memory database: %v", err)
|
|
}
|
|
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
t.Fatalf("failed to access underlying database: %v", err)
|
|
}
|
|
sqlDB.SetMaxOpenConns(1)
|
|
sqlDB.SetMaxIdleConns(1)
|
|
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
|
|
|
err = db.AutoMigrate(
|
|
&database.User{},
|
|
&database.Post{},
|
|
&database.Vote{},
|
|
&database.AccountDeletionRequest{},
|
|
&database.RefreshToken{},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("failed to migrate database: %v", err)
|
|
}
|
|
|
|
if execErr := db.Exec("PRAGMA busy_timeout = 5000").Error; execErr != nil {
|
|
t.Fatalf("failed to configure busy timeout: %v", execErr)
|
|
}
|
|
if execErr := db.Exec("PRAGMA foreign_keys = ON").Error; execErr != nil {
|
|
t.Fatalf("failed to enable foreign keys: %v", execErr)
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
func setupRepositories(db *gorm.DB) (repositories.UserRepository, repositories.PostRepository, repositories.VoteRepository, repositories.AccountDeletionRepository, *repositories.RefreshTokenRepository) {
|
|
return repositories.NewUserRepository(db),
|
|
repositories.NewPostRepository(db),
|
|
repositories.NewVoteRepository(db),
|
|
repositories.NewAccountDeletionRepository(db),
|
|
repositories.NewRefreshTokenRepository(db)
|
|
}
|
|
|
|
func setupServices(cfg *config.Config, userRepo repositories.UserRepository, postRepo repositories.PostRepository, deletionRepo repositories.AccountDeletionRepository, refreshTokenRepo *repositories.RefreshTokenRepository, emailSender *testutils.MockEmailSender, voteRepo repositories.VoteRepository, db *gorm.DB) (handlers.AuthServiceInterface, *services.VoteService, error) {
|
|
authService, err := services.NewAuthFacadeForTest(cfg, userRepo, postRepo, deletionRepo, refreshTokenRepo, emailSender)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
voteService := services.NewVoteService(voteRepo, postRepo, db)
|
|
return authService, voteService, nil
|
|
}
|
|
|
|
func setupHandlers(authService handlers.AuthServiceInterface, userRepo repositories.UserRepository, postRepo repositories.PostRepository, voteService *services.VoteService, cfg *config.Config) (*handlers.AuthHandler, *handlers.PostHandler, *handlers.VoteHandler, *handlers.UserHandler, *handlers.APIHandler) {
|
|
return handlers.NewAuthHandler(authService, userRepo),
|
|
handlers.NewPostHandler(postRepo, nil, voteService),
|
|
handlers.NewVoteHandler(voteService),
|
|
handlers.NewUserHandler(userRepo, authService),
|
|
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 {
|
|
return server.NewRouter(server.RouterConfig{
|
|
AuthHandler: authHandler,
|
|
PostHandler: postHandler,
|
|
VoteHandler: voteHandler,
|
|
UserHandler: userHandler,
|
|
APIHandler: apiHandler,
|
|
AuthService: authService,
|
|
PageHandler: nil,
|
|
StaticDir: findWorkspaceRoot() + "/internal/static/",
|
|
Debug: false,
|
|
DisableCache: true,
|
|
DisableCompression: true,
|
|
RateLimitConfig: cfg.RateLimit,
|
|
})
|
|
}
|
|
|
|
func setupIntegrationTestServerWithConfig(t *testing.T, serverCfg serverConfig) *IntegrationTestServer {
|
|
t.Helper()
|
|
|
|
originalTrustProxy := middleware.TrustProxyHeaders
|
|
middleware.TrustProxyHeaders = true
|
|
t.Cleanup(func() {
|
|
middleware.TrustProxyHeaders = originalTrustProxy
|
|
})
|
|
|
|
db := setupDatabase(t)
|
|
userRepo, postRepo, voteRepo, deletionRepo, refreshTokenRepo := setupRepositories(db)
|
|
|
|
cfg := &config.Config{
|
|
JWT: config.JWTConfig{
|
|
Secret: "test-secret-key-for-testing-purposes-only",
|
|
Expiration: 24,
|
|
RefreshExpiration: 168,
|
|
Issuer: "goyco",
|
|
Audience: "goyco-users",
|
|
},
|
|
App: config.AppConfig{
|
|
BaseURL: "http://localhost:8080",
|
|
BcryptCost: 10,
|
|
},
|
|
RateLimit: config.RateLimitConfig{
|
|
AuthLimit: serverCfg.authLimit,
|
|
GeneralLimit: 10000,
|
|
HealthLimit: 10000,
|
|
MetricsLimit: 10000,
|
|
TrustProxyHeaders: true,
|
|
},
|
|
}
|
|
|
|
mockEmailSender := &testutils.MockEmailSender{}
|
|
authService, voteService, err := setupServices(cfg, userRepo, postRepo, deletionRepo, refreshTokenRepo, mockEmailSender, voteRepo, db)
|
|
if err != nil {
|
|
t.Fatalf("failed to create auth service: %v", err)
|
|
}
|
|
|
|
authHandler, postHandler, voteHandler, userHandler, apiHandler := setupHandlers(authService, userRepo, postRepo, voteService, cfg)
|
|
router := setupRouter(authHandler, postHandler, voteHandler, userHandler, apiHandler, authService, cfg)
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
|
|
var (
|
|
httpServer *httptest.Server
|
|
baseURL string
|
|
transport http.RoundTripper
|
|
closeFunc func()
|
|
)
|
|
|
|
if err == nil {
|
|
httpServer = httptest.NewUnstartedServer(router)
|
|
httpServer.Listener = listener
|
|
httpServer.Start()
|
|
|
|
baseURL = httpServer.URL
|
|
transport = nil
|
|
closeFunc = func() {
|
|
httpServer.Close()
|
|
}
|
|
} else {
|
|
t.Logf("falling back to in-memory http server: %v", err)
|
|
baseURL = "http://inmemory.goyco"
|
|
transport = newInMemoryRoundTripper(router)
|
|
closeFunc = func() {}
|
|
}
|
|
|
|
return &IntegrationTestServer{
|
|
DB: db,
|
|
Server: httpServer,
|
|
baseURL: baseURL,
|
|
transport: transport,
|
|
closeFunc: closeFunc,
|
|
UserRepo: userRepo,
|
|
PostRepo: postRepo,
|
|
VoteRepo: voteRepo,
|
|
RefreshTokenRepo: refreshTokenRepo,
|
|
AuthService: authService,
|
|
VoteService: voteService,
|
|
AuthHandler: authHandler,
|
|
PostHandler: postHandler,
|
|
VoteHandler: voteHandler,
|
|
UserHandler: userHandler,
|
|
APIHandler: apiHandler,
|
|
EmailSender: mockEmailSender,
|
|
}
|
|
}
|
|
|
|
func (ctx *testContext) resendVerification(t *testing.T, email string) int {
|
|
t.Helper()
|
|
return testutils.ResendVerificationEmail(t, ctx.client, ctx.baseURL, email)
|
|
}
|
|
|
|
func (ctx *testContext) createTestFixtures(t *testing.T) *TestFixtures {
|
|
t.Helper()
|
|
fixtures := &TestFixtures{}
|
|
|
|
fixtures.VerifiedUser = ctx.createUserWithCleanup(t, "verified", "Password123!")
|
|
ctx.confirmEmail(t, ctx.server.EmailSender.VerificationToken())
|
|
|
|
fixtures.UnverifiedUser = ctx.createUserWithCleanup(t, "unverified", "Password123!")
|
|
|
|
lockedUser := ctx.createUserWithCleanup(t, "locked", "Password123!")
|
|
ctx.server.UserRepo.Update(&database.User{
|
|
ID: lockedUser.ID,
|
|
Locked: true,
|
|
})
|
|
fixtures.LockedUser = lockedUser
|
|
|
|
client := ctx.loginUser(t, fixtures.VerifiedUser.Username, fixtures.VerifiedUser.Password)
|
|
fixtures.PostWithVotes = client.CreatePost(t, "Post With Votes", "https://example.com/votes", "Content")
|
|
client.VoteOnPost(t, fixtures.PostWithVotes.ID, "up")
|
|
client2 := ctx.loginUser(t, fixtures.VerifiedUser.Username, fixtures.VerifiedUser.Password)
|
|
client2.VoteOnPost(t, fixtures.PostWithVotes.ID, "up")
|
|
|
|
fixtures.PostNoVotes = client.CreatePost(t, "Post No Votes", "https://example.com/novotes", "Content")
|
|
|
|
return fixtures
|
|
}
|
|
|
|
func (ctx *testContext) waitForCondition(t *testing.T, condition func() bool, timeout time.Duration) bool {
|
|
t.Helper()
|
|
ctxTimeout, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(50 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctxTimeout.Done():
|
|
return false
|
|
case <-ticker.C:
|
|
if condition() {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (ctx *testContext) assertEventually(t *testing.T, assertion func() bool, timeout time.Duration) {
|
|
t.Helper()
|
|
if !ctx.waitForCondition(t, assertion, timeout) {
|
|
t.Errorf("Assertion failed after %v timeout", timeout)
|
|
}
|
|
}
|
|
|
|
var (
|
|
assertPostInList = testutils.AssertPostInList
|
|
getHealth = testutils.GetHealth
|
|
getMetrics = testutils.GetMetrics
|
|
assertVoteData = testutils.AssertVoteData
|
|
)
|
|
|
|
func assertUserResponse(t *testing.T, resp *ProfileResponse, expectedUser *TestUser) {
|
|
t.Helper()
|
|
if resp == nil {
|
|
t.Fatalf("Expected user response, got nil")
|
|
}
|
|
if !resp.Success {
|
|
t.Errorf("Expected user response success=true, got false: %s", resp.Message)
|
|
}
|
|
if resp.Data.ID != expectedUser.ID {
|
|
t.Errorf("Expected user ID %d, got %d", expectedUser.ID, resp.Data.ID)
|
|
}
|
|
if resp.Data.Username != expectedUser.Username {
|
|
t.Errorf("Expected username '%s', got '%s'", expectedUser.Username, resp.Data.Username)
|
|
}
|
|
if resp.Data.Email != expectedUser.Email {
|
|
t.Errorf("Expected email '%s', got '%s'", expectedUser.Email, resp.Data.Email)
|
|
}
|
|
if resp.Data.CreatedAt == "" {
|
|
t.Errorf("Expected user CreatedAt to be set")
|
|
}
|
|
if resp.Data.UpdatedAt == "" {
|
|
t.Errorf("Expected user UpdatedAt to be set")
|
|
}
|
|
validateTimestamp(t, resp.Data.CreatedAt, "CreatedAt")
|
|
validateTimestamp(t, resp.Data.UpdatedAt, "UpdatedAt")
|
|
}
|
|
|
|
func assertVoteResponse(t *testing.T, resp *VoteResponse, expectedType string) {
|
|
t.Helper()
|
|
if resp == nil {
|
|
t.Fatalf("Expected vote response, got nil")
|
|
}
|
|
if !resp.Success {
|
|
t.Errorf("Expected vote response success=true, got false: %s", resp.Message)
|
|
}
|
|
if resp.Data == nil {
|
|
t.Errorf("Expected vote data to be present")
|
|
return
|
|
}
|
|
voteData, ok := resp.Data.(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
if voteType, exists := voteData["type"]; exists {
|
|
if voteTypeStr, ok := voteType.(string); ok && voteTypeStr != expectedType {
|
|
t.Errorf("Expected vote type '%s', got '%s'", expectedType, voteTypeStr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func validateTimestamp(t *testing.T, timestampStr, fieldName string) {
|
|
t.Helper()
|
|
if timestampStr == "" {
|
|
t.Errorf("Expected %s to be set", fieldName)
|
|
return
|
|
}
|
|
_, err := time.Parse(time.RFC3339, timestampStr)
|
|
if err != nil {
|
|
_, err = time.Parse("2006-01-02T15:04:05Z07:00", timestampStr)
|
|
if err != nil {
|
|
t.Errorf("Invalid timestamp format for %s: '%s'", fieldName, timestampStr)
|
|
}
|
|
}
|
|
}
|