To gitea and beyond, let's go(-yco)
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Database DatabaseConfig
|
||||
Server ServerConfig
|
||||
JWT JWTConfig
|
||||
SMTP SMTPConfig
|
||||
App AppConfig
|
||||
RateLimit RateLimitConfig
|
||||
LogDir string
|
||||
PIDDir string
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
Host string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
EnableTLS bool
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
Expiration int
|
||||
RefreshExpiration int
|
||||
Issuer string
|
||||
Audience string
|
||||
KeyRotation KeyRotationConfig
|
||||
}
|
||||
|
||||
type KeyRotationConfig struct {
|
||||
Enabled bool
|
||||
CurrentKey string
|
||||
PreviousKey string
|
||||
KeyID string
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
BaseURL string
|
||||
Debug bool
|
||||
AdminEmail string
|
||||
BcryptCost int
|
||||
Title string
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
AuthLimit int
|
||||
GeneralLimit int
|
||||
HealthLimit int
|
||||
MetricsLimit int
|
||||
TrustProxyHeaders bool
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
config := &Config{
|
||||
Database: DatabaseConfig{
|
||||
Host: getEnv("DB_HOST", "localhost"),
|
||||
Port: getEnv("DB_PORT", "5432"),
|
||||
User: getEnv("DB_USER", "postgres"),
|
||||
Password: getEnv("DB_PASSWORD", ""),
|
||||
Name: getEnv("DB_NAME", "goyco"),
|
||||
SSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||
},
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("SERVER_PORT", "8080"),
|
||||
Host: getEnv("SERVER_HOST", "0.0.0.0"),
|
||||
ReadTimeout: time.Duration(getEnvAsInt("SERVER_READ_TIMEOUT", 30)) * time.Second,
|
||||
WriteTimeout: time.Duration(getEnvAsInt("SERVER_WRITE_TIMEOUT", 30)) * time.Second,
|
||||
IdleTimeout: time.Duration(getEnvAsInt("SERVER_IDLE_TIMEOUT", 120)) * time.Second,
|
||||
MaxHeaderBytes: getEnvAsInt("SERVER_MAX_HEADER_BYTES", 1<<20),
|
||||
EnableTLS: getEnvAsBool("SERVER_ENABLE_TLS", false),
|
||||
TLSCertFile: getEnv("SERVER_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getEnv("SERVER_TLS_KEY_FILE", ""),
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Secret: getEnv("JWT_SECRET", "your-secret-key"),
|
||||
Expiration: getEnvAsInt("JWT_EXPIRATION", 1),
|
||||
RefreshExpiration: getEnvAsInt("JWT_REFRESH_EXPIRATION", 168),
|
||||
Issuer: getEnv("JWT_ISSUER", "goyco"),
|
||||
Audience: getEnv("JWT_AUDIENCE", "goyco-users"),
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: getEnvAsBool("JWT_KEY_ROTATION_ENABLED", false),
|
||||
CurrentKey: getEnv("JWT_CURRENT_KEY", ""),
|
||||
PreviousKey: getEnv("JWT_PREVIOUS_KEY", ""),
|
||||
KeyID: getEnv("JWT_KEY_ID", "default"),
|
||||
},
|
||||
},
|
||||
SMTP: SMTPConfig{
|
||||
Host: getEnv("SMTP_HOST", ""),
|
||||
Port: getEnvAsInt("SMTP_PORT", 587),
|
||||
Username: getEnv("SMTP_USERNAME", ""),
|
||||
Password: getEnv("SMTP_PASSWORD", ""),
|
||||
From: getEnv("SMTP_FROM", ""),
|
||||
Timeout: time.Duration(getEnvAsInt("SMTP_TIMEOUT", 30)) * time.Second,
|
||||
},
|
||||
App: AppConfig{
|
||||
BaseURL: getEnv("APP_BASE_URL", ""),
|
||||
Debug: getEnvAsBool("DEBUG", false),
|
||||
AdminEmail: getEnv("ADMIN_EMAIL", ""),
|
||||
BcryptCost: getEnvAsInt("BCRYPT_COST", 10),
|
||||
Title: getEnv("TITLE", "Goyco"),
|
||||
},
|
||||
RateLimit: RateLimitConfig{
|
||||
AuthLimit: getEnvAsInt("RATE_LIMIT_AUTH", 5),
|
||||
GeneralLimit: getEnvAsInt("RATE_LIMIT_GENERAL", 100),
|
||||
HealthLimit: getEnvAsInt("RATE_LIMIT_HEALTH", 60),
|
||||
MetricsLimit: getEnvAsInt("RATE_LIMIT_METRICS", 10),
|
||||
TrustProxyHeaders: getEnvAsBool("RATE_LIMIT_TRUST_PROXY", false),
|
||||
},
|
||||
LogDir: getEnv("LOG_DIR", "/var/log/"),
|
||||
PIDDir: getEnv("PID_DIR", "/run"),
|
||||
}
|
||||
|
||||
if config.App.BaseURL == "" {
|
||||
config.App.BaseURL = fmt.Sprintf("http://%s:%s", config.Server.Host, config.Server.Port)
|
||||
}
|
||||
|
||||
if config.Database.Password == "" {
|
||||
return nil, fmt.Errorf("DB_PASSWORD is required")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.SMTP.Host) == "" {
|
||||
return nil, fmt.Errorf("SMTP_HOST is required")
|
||||
}
|
||||
|
||||
if config.SMTP.Port <= 0 {
|
||||
return nil, fmt.Errorf("SMTP_PORT must be greater than 0")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.SMTP.From) == "" {
|
||||
return nil, fmt.Errorf("SMTP_FROM is required")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.App.AdminEmail) == "" {
|
||||
return nil, fmt.Errorf("ADMIN_EMAIL is required")
|
||||
}
|
||||
|
||||
if config.Server.EnableTLS {
|
||||
if strings.TrimSpace(config.Server.TLSCertFile) == "" {
|
||||
return nil, fmt.Errorf("SERVER_TLS_CERT_FILE is required when SERVER_ENABLE_TLS is true")
|
||||
}
|
||||
if strings.TrimSpace(config.Server.TLSKeyFile) == "" {
|
||||
return nil, fmt.Errorf("SERVER_TLS_KEY_FILE is required when SERVER_ENABLE_TLS is true")
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateJWTConfig(&config.JWT); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateAppConfig(&config.App); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (c *Config) GetConnectionString() string {
|
||||
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s client_encoding=UTF8",
|
||||
c.Database.Host,
|
||||
c.Database.Port,
|
||||
c.Database.User,
|
||||
c.Database.Password,
|
||||
c.Database.Name,
|
||||
c.Database.SSLMode,
|
||||
)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if intValue, err := strconv.Atoi(value); err == nil {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsBool(key string, defaultValue bool) bool {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if boolValue, err := strconv.ParseBool(value); err == nil {
|
||||
return boolValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func validateJWTConfig(jwt *JWTConfig) error {
|
||||
if err := validateJWTSecret(jwt.Secret); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(jwt.Issuer) == "" {
|
||||
return fmt.Errorf("JWT_ISSUER is required and cannot be empty")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(jwt.Audience) == "" {
|
||||
return fmt.Errorf("JWT_AUDIENCE is required and cannot be empty")
|
||||
}
|
||||
|
||||
if jwt.Expiration <= 0 {
|
||||
return fmt.Errorf("JWT_EXPIRATION must be greater than 0")
|
||||
}
|
||||
|
||||
if jwt.RefreshExpiration <= 0 {
|
||||
return fmt.Errorf("JWT_REFRESH_EXPIRATION must be greater than 0")
|
||||
}
|
||||
|
||||
if jwt.RefreshExpiration <= jwt.Expiration {
|
||||
return fmt.Errorf("JWT_REFRESH_EXPIRATION must be greater than JWT_EXPIRATION")
|
||||
}
|
||||
|
||||
if jwt.KeyRotation.Enabled {
|
||||
if strings.TrimSpace(jwt.KeyRotation.CurrentKey) == "" {
|
||||
return fmt.Errorf("JWT_CURRENT_KEY is required when key rotation is enabled")
|
||||
}
|
||||
|
||||
if err := validateJWTSecret(jwt.KeyRotation.CurrentKey); err != nil {
|
||||
return fmt.Errorf("JWT_CURRENT_KEY validation failed: %w", err)
|
||||
}
|
||||
|
||||
if jwt.KeyRotation.PreviousKey != "" {
|
||||
if err := validateJWTSecret(jwt.KeyRotation.PreviousKey); err != nil {
|
||||
return fmt.Errorf("JWT_PREVIOUS_KEY validation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(jwt.KeyRotation.KeyID) == "" {
|
||||
return fmt.Errorf("JWT_KEY_ID is required when key rotation is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJWTSecret(secret string) error {
|
||||
trimmed := strings.TrimSpace(secret)
|
||||
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("JWT secret is required and cannot be empty")
|
||||
}
|
||||
|
||||
invalidSecrets := []string{
|
||||
"your-secret-key",
|
||||
"secret",
|
||||
"jwt-secret",
|
||||
"my-secret",
|
||||
"change-me",
|
||||
"default-secret",
|
||||
"123456",
|
||||
"password",
|
||||
"admin",
|
||||
"test",
|
||||
"development",
|
||||
"production",
|
||||
"staging",
|
||||
}
|
||||
|
||||
for _, invalid := range invalidSecrets {
|
||||
if strings.EqualFold(trimmed, invalid) {
|
||||
return fmt.Errorf("JWT secret cannot be a placeholder value like %q - please set a secure, random secret", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(trimmed) < 32 {
|
||||
return fmt.Errorf("JWT secret must be at least 32 characters long for security (current length: %d)", len(trimmed))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAppConfig(app *AppConfig) error {
|
||||
|
||||
if app.BcryptCost < 10 {
|
||||
return fmt.Errorf("BCRYPT_COST must be at least 10 for security (current: %d)", app.BcryptCost)
|
||||
}
|
||||
if app.BcryptCost > 14 {
|
||||
return fmt.Errorf("BCRYPT_COST must be at most 14 to avoid performance issues (current: %d)", app.BcryptCost)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadSuccess(t *testing.T) {
|
||||
t.Setenv("DB_HOST", "db.example.com")
|
||||
t.Setenv("DB_PORT", "5439")
|
||||
t.Setenv("DB_USER", "goyco")
|
||||
t.Setenv("DB_PASSWORD", "super-secret")
|
||||
t.Setenv("DB_NAME", "goycodb")
|
||||
t.Setenv("DB_SSLMODE", "require")
|
||||
t.Setenv("SERVER_PORT", "9090")
|
||||
t.Setenv("SERVER_HOST", "127.0.0.1")
|
||||
t.Setenv("JWT_SECRET", "this-is-a-very-secure-jwt-secret-key-that-is-long-enough")
|
||||
t.Setenv("JWT_EXPIRATION", "12")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "2525")
|
||||
t.Setenv("SMTP_USERNAME", "mailer")
|
||||
t.Setenv("SMTP_PASSWORD", "mail-secret")
|
||||
t.Setenv("SMTP_FROM", "no-reply@example.com")
|
||||
t.Setenv("APP_BASE_URL", "https://goyco.example.com")
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
t.Setenv("TITLE", "My Custom Site")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Database.Host != "db.example.com" || cfg.Database.Port != "5439" || cfg.Database.User != "goyco" {
|
||||
t.Fatalf("unexpected database config: %+v", cfg.Database)
|
||||
}
|
||||
|
||||
if cfg.Database.Password != "super-secret" || cfg.Database.Name != "goycodb" || cfg.Database.SSLMode != "require" {
|
||||
t.Fatalf("unexpected database credentials: %+v", cfg.Database)
|
||||
}
|
||||
|
||||
if cfg.Server.Port != "9090" || cfg.Server.Host != "127.0.0.1" {
|
||||
t.Fatalf("unexpected server config: %+v", cfg.Server)
|
||||
}
|
||||
|
||||
if cfg.JWT.Secret != "this-is-a-very-secure-jwt-secret-key-that-is-long-enough" {
|
||||
t.Fatalf("unexpected jwt secret: %q", cfg.JWT.Secret)
|
||||
}
|
||||
|
||||
if cfg.JWT.Expiration != 12 {
|
||||
t.Fatalf("expected JWT expiration 12, got %d", cfg.JWT.Expiration)
|
||||
}
|
||||
|
||||
if cfg.SMTP.Host != "smtp.example.com" || cfg.SMTP.Port != 2525 {
|
||||
t.Fatalf("unexpected smtp host/port: %+v", cfg.SMTP)
|
||||
}
|
||||
|
||||
if cfg.SMTP.Username != "mailer" || cfg.SMTP.Password != "mail-secret" || cfg.SMTP.From != "no-reply@example.com" {
|
||||
t.Fatalf("unexpected smtp credentials: %+v", cfg.SMTP)
|
||||
}
|
||||
|
||||
if cfg.App.BaseURL != "https://goyco.example.com" {
|
||||
t.Fatalf("expected base url to be overridden, got %q", cfg.App.BaseURL)
|
||||
}
|
||||
|
||||
if cfg.App.Title != "My Custom Site" {
|
||||
t.Fatalf("expected title to be 'My Custom Site', got %q", cfg.App.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingPassword(t *testing.T) {
|
||||
t.Setenv("DB_PASSWORD", "")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "2525")
|
||||
t.Setenv("SMTP_FROM", "no-reply@example.com")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatalf("expected error when DB_PASSWORD is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultBaseURL(t *testing.T) {
|
||||
t.Setenv("DB_PASSWORD", "pw")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "2525")
|
||||
t.Setenv("SMTP_FROM", "no-reply@example.com")
|
||||
t.Setenv("JWT_SECRET", "this-is-a-very-secure-jwt-secret-key-that-is-long-enough")
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected load to succeed, got %v", err)
|
||||
}
|
||||
|
||||
if cfg.App.BaseURL != "http://0.0.0.0:8080" {
|
||||
t.Fatalf("expected default base url http://0.0.0.0:8080, got %q", cfg.App.BaseURL)
|
||||
}
|
||||
|
||||
if cfg.App.Title != "Goyco" {
|
||||
t.Fatalf("expected default title to be 'Goyco', got %q", cfg.App.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigGetConnectionString(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Database: DatabaseConfig{
|
||||
Host: "db",
|
||||
Port: "5432",
|
||||
User: "user",
|
||||
Password: "pass",
|
||||
Name: "dbname",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
}
|
||||
|
||||
got := cfg.GetConnectionString()
|
||||
expected := "host=db port=5432 user=user password=pass dbname=dbname sslmode=disable client_encoding=UTF8"
|
||||
|
||||
if got != expected {
|
||||
t.Fatalf("expected connection string %q, got %q", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnv(t *testing.T) {
|
||||
const key = "CONFIG_TEST_ENV"
|
||||
|
||||
t.Setenv(key, "value")
|
||||
if got := getEnv(key, "default"); got != "value" {
|
||||
t.Fatalf("expected %q, got %q", "value", got)
|
||||
}
|
||||
|
||||
if got := getEnv(key+"_MISSING", "fallback"); got != "fallback" {
|
||||
t.Fatalf("expected fallback value, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvAsInt(t *testing.T) {
|
||||
const key = "CONFIG_TEST_INT"
|
||||
|
||||
t.Setenv(key, "42")
|
||||
if got := getEnvAsInt(key, 1); got != 42 {
|
||||
t.Fatalf("expected 42, got %d", got)
|
||||
}
|
||||
|
||||
t.Setenv(key, "not-a-number")
|
||||
if got := getEnvAsInt(key, 5); got != 5 {
|
||||
t.Fatalf("expected default 5 when invalid int, got %d", got)
|
||||
}
|
||||
|
||||
t.Setenv(key, "")
|
||||
if got := getEnvAsInt(key, 7); got != 7 {
|
||||
t.Fatalf("expected default 7 when env empty, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJWTSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secret string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid long secret",
|
||||
secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid secret with special chars",
|
||||
secret: "MyV3ry$ecure&JWT!Secret#Key@2024-With-Special-Chars",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty secret",
|
||||
secret: "",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only secret",
|
||||
secret: " ",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "too short secret",
|
||||
secret: "short",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret must be at least 32 characters long for security",
|
||||
},
|
||||
{
|
||||
name: "default placeholder secret",
|
||||
secret: "your-secret-key",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret cannot be a placeholder value like \"your-secret-key\"",
|
||||
},
|
||||
{
|
||||
name: "common placeholder secret",
|
||||
secret: "secret",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret cannot be a placeholder value like \"secret\"",
|
||||
},
|
||||
{
|
||||
name: "test placeholder secret",
|
||||
secret: "test",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret cannot be a placeholder value like \"test\"",
|
||||
},
|
||||
{
|
||||
name: "development placeholder secret",
|
||||
secret: "development",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret cannot be a placeholder value like \"development\"",
|
||||
},
|
||||
{
|
||||
name: "case insensitive placeholder",
|
||||
secret: "SECRET",
|
||||
expectError: true,
|
||||
errorMsg: "JWT secret cannot be a placeholder value like \"secret\"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateJWTSecret(tt.secret)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for secret %q, got nil", tt.secret)
|
||||
}
|
||||
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for secret %q: %v", tt.secret, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithInvalidJWTSecret(t *testing.T) {
|
||||
t.Setenv("DB_PASSWORD", "password")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "2525")
|
||||
t.Setenv("SMTP_FROM", "no-reply@example.com")
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
|
||||
t.Setenv("JWT_SECRET", "your-secret-key")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when JWT_SECRET is placeholder value")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "your-secret-key") {
|
||||
t.Fatalf("expected error message to mention placeholder value, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJWTConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config JWTConfig
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty issuer",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_ISSUER is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only issuer",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: " ",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_ISSUER is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "empty audience",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_AUDIENCE is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only audience",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: " ",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_AUDIENCE is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "zero expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 0,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_EXPIRATION must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "negative expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: -1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_EXPIRATION must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "zero refresh expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 0,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_REFRESH_EXPIRATION must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "negative refresh expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: -1,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_REFRESH_EXPIRATION must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "refresh expiration not greater than access expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 24,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_REFRESH_EXPIRATION must be greater than JWT_EXPIRATION",
|
||||
},
|
||||
{
|
||||
name: "refresh expiration less than access expiration",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 24,
|
||||
RefreshExpiration: 12,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_REFRESH_EXPIRATION must be greater than JWT_EXPIRATION",
|
||||
},
|
||||
{
|
||||
name: "key rotation enabled but no current key",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: true,
|
||||
CurrentKey: "",
|
||||
PreviousKey: "",
|
||||
KeyID: "test-key",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_CURRENT_KEY is required when key rotation is enabled",
|
||||
},
|
||||
{
|
||||
name: "key rotation enabled but no key ID",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: true,
|
||||
CurrentKey: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
PreviousKey: "",
|
||||
KeyID: "",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_KEY_ID is required when key rotation is enabled",
|
||||
},
|
||||
{
|
||||
name: "key rotation enabled with invalid current key",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: true,
|
||||
CurrentKey: "short",
|
||||
PreviousKey: "",
|
||||
KeyID: "test-key",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_CURRENT_KEY validation failed",
|
||||
},
|
||||
{
|
||||
name: "key rotation enabled with invalid previous key",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: true,
|
||||
CurrentKey: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
PreviousKey: "short",
|
||||
KeyID: "test-key",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_PREVIOUS_KEY validation failed",
|
||||
},
|
||||
{
|
||||
name: "valid key rotation config",
|
||||
config: JWTConfig{
|
||||
Secret: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
Expiration: 1,
|
||||
RefreshExpiration: 24,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
KeyRotation: KeyRotationConfig{
|
||||
Enabled: true,
|
||||
CurrentKey: "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
PreviousKey: "this-is-another-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
KeyID: "test-key",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateJWTConfig(&tt.config)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for config %+v, got nil", tt.config)
|
||||
}
|
||||
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for config %+v: %v", tt.config, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithInvalidJWTConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envVars map[string]string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "whitespace only issuer",
|
||||
envVars: map[string]string{
|
||||
"DB_PASSWORD": "password",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_FROM": "no-reply@example.com",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"JWT_SECRET": "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
"JWT_ISSUER": " ",
|
||||
"JWT_AUDIENCE": "goyco-users",
|
||||
"JWT_EXPIRATION": "1",
|
||||
"JWT_REFRESH_EXPIRATION": "24",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_ISSUER is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only audience",
|
||||
envVars: map[string]string{
|
||||
"DB_PASSWORD": "password",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_FROM": "no-reply@example.com",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"JWT_SECRET": "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
"JWT_ISSUER": "goyco",
|
||||
"JWT_AUDIENCE": " ",
|
||||
"JWT_EXPIRATION": "1",
|
||||
"JWT_REFRESH_EXPIRATION": "24",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_AUDIENCE is required and cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "zero expiration",
|
||||
envVars: map[string]string{
|
||||
"DB_PASSWORD": "password",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_FROM": "no-reply@example.com",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"JWT_SECRET": "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
"JWT_ISSUER": "goyco",
|
||||
"JWT_AUDIENCE": "goyco-users",
|
||||
"JWT_EXPIRATION": "0",
|
||||
"JWT_REFRESH_EXPIRATION": "24",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_EXPIRATION must be greater than 0",
|
||||
},
|
||||
{
|
||||
name: "refresh expiration not greater than access expiration",
|
||||
envVars: map[string]string{
|
||||
"DB_PASSWORD": "password",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_FROM": "no-reply@example.com",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"JWT_SECRET": "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
"JWT_ISSUER": "goyco",
|
||||
"JWT_AUDIENCE": "goyco-users",
|
||||
"JWT_EXPIRATION": "24",
|
||||
"JWT_REFRESH_EXPIRATION": "24",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_REFRESH_EXPIRATION must be greater than JWT_EXPIRATION",
|
||||
},
|
||||
{
|
||||
name: "key rotation enabled but no current key",
|
||||
envVars: map[string]string{
|
||||
"DB_PASSWORD": "password",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PORT": "2525",
|
||||
"SMTP_FROM": "no-reply@example.com",
|
||||
"ADMIN_EMAIL": "admin@example.com",
|
||||
"JWT_SECRET": "this-is-a-very-secure-jwt-secret-key-that-is-long-enough",
|
||||
"JWT_ISSUER": "goyco",
|
||||
"JWT_AUDIENCE": "goyco-users",
|
||||
"JWT_EXPIRATION": "1",
|
||||
"JWT_REFRESH_EXPIRATION": "24",
|
||||
"JWT_KEY_ROTATION_ENABLED": "true",
|
||||
"JWT_CURRENT_KEY": "",
|
||||
"JWT_KEY_ID": "test-key",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "JWT_CURRENT_KEY is required when key rotation is enabled",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
envVars := []string{
|
||||
"JWT_SECRET", "JWT_ISSUER", "JWT_AUDIENCE", "JWT_EXPIRATION", "JWT_REFRESH_EXPIRATION",
|
||||
"JWT_KEY_ROTATION_ENABLED", "JWT_CURRENT_KEY", "JWT_PREVIOUS_KEY", "JWT_KEY_ID",
|
||||
}
|
||||
for _, envVar := range envVars {
|
||||
t.Setenv(envVar, "")
|
||||
}
|
||||
|
||||
for key, value := range tt.envVars {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
|
||||
_, err := Load()
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("expected error but got nil")
|
||||
}
|
||||
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigDefaults(t *testing.T) {
|
||||
envVars := []string{
|
||||
"SERVER_READ_TIMEOUT",
|
||||
"SERVER_WRITE_TIMEOUT",
|
||||
"SERVER_IDLE_TIMEOUT",
|
||||
"SERVER_MAX_HEADER_BYTES",
|
||||
"SERVER_ENABLE_TLS",
|
||||
"SERVER_TLS_CERT_FILE",
|
||||
"SERVER_TLS_KEY_FILE",
|
||||
}
|
||||
|
||||
for _, envVar := range envVars {
|
||||
os.Unsetenv(envVar)
|
||||
}
|
||||
|
||||
os.Setenv("DB_PASSWORD", "testpassword")
|
||||
os.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
os.Setenv("SMTP_FROM", "test@example.com")
|
||||
os.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
os.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes-only")
|
||||
|
||||
config, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if config.Server.ReadTimeout != 30*time.Second {
|
||||
t.Errorf("Expected ReadTimeout to be 30s, got %v", config.Server.ReadTimeout)
|
||||
}
|
||||
|
||||
if config.Server.WriteTimeout != 30*time.Second {
|
||||
t.Errorf("Expected WriteTimeout to be 30s, got %v", config.Server.WriteTimeout)
|
||||
}
|
||||
|
||||
if config.Server.IdleTimeout != 120*time.Second {
|
||||
t.Errorf("Expected IdleTimeout to be 120s, got %v", config.Server.IdleTimeout)
|
||||
}
|
||||
|
||||
if config.Server.MaxHeaderBytes != 1<<20 {
|
||||
t.Errorf("Expected MaxHeaderBytes to be 1MB, got %d", config.Server.MaxHeaderBytes)
|
||||
}
|
||||
|
||||
if config.Server.EnableTLS {
|
||||
t.Error("Expected EnableTLS to be false by default")
|
||||
}
|
||||
|
||||
for _, envVar := range envVars {
|
||||
os.Unsetenv(envVar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigCustomValues(t *testing.T) {
|
||||
os.Setenv("DB_PASSWORD", "testpassword")
|
||||
os.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
os.Setenv("SMTP_FROM", "test@example.com")
|
||||
os.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
os.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes-only")
|
||||
os.Setenv("SERVER_READ_TIMEOUT", "60")
|
||||
os.Setenv("SERVER_WRITE_TIMEOUT", "45")
|
||||
os.Setenv("SERVER_IDLE_TIMEOUT", "180")
|
||||
os.Setenv("SERVER_MAX_HEADER_BYTES", "2097152")
|
||||
os.Setenv("SERVER_ENABLE_TLS", "true")
|
||||
os.Setenv("SERVER_TLS_CERT_FILE", "/path/to/cert.pem")
|
||||
os.Setenv("SERVER_TLS_KEY_FILE", "/path/to/key.pem")
|
||||
|
||||
config, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if config.Server.ReadTimeout != 60*time.Second {
|
||||
t.Errorf("Expected ReadTimeout to be 60s, got %v", config.Server.ReadTimeout)
|
||||
}
|
||||
|
||||
if config.Server.WriteTimeout != 45*time.Second {
|
||||
t.Errorf("Expected WriteTimeout to be 45s, got %v", config.Server.WriteTimeout)
|
||||
}
|
||||
|
||||
if config.Server.IdleTimeout != 180*time.Second {
|
||||
t.Errorf("Expected IdleTimeout to be 180s, got %v", config.Server.IdleTimeout)
|
||||
}
|
||||
|
||||
if config.Server.MaxHeaderBytes != 2<<20 {
|
||||
t.Errorf("Expected MaxHeaderBytes to be 2MB, got %d", config.Server.MaxHeaderBytes)
|
||||
}
|
||||
|
||||
if !config.Server.EnableTLS {
|
||||
t.Error("Expected EnableTLS to be true")
|
||||
}
|
||||
|
||||
if config.Server.TLSCertFile != "/path/to/cert.pem" {
|
||||
t.Errorf("Expected TLSCertFile to be /path/to/cert.pem, got %s", config.Server.TLSCertFile)
|
||||
}
|
||||
|
||||
if config.Server.TLSKeyFile != "/path/to/key.pem" {
|
||||
t.Errorf("Expected TLSKeyFile to be /path/to/key.pem, got %s", config.Server.TLSKeyFile)
|
||||
}
|
||||
|
||||
envVars := []string{
|
||||
"SERVER_READ_TIMEOUT",
|
||||
"SERVER_WRITE_TIMEOUT",
|
||||
"SERVER_IDLE_TIMEOUT",
|
||||
"SERVER_MAX_HEADER_BYTES",
|
||||
"SERVER_ENABLE_TLS",
|
||||
"SERVER_TLS_CERT_FILE",
|
||||
"SERVER_TLS_KEY_FILE",
|
||||
}
|
||||
for _, envVar := range envVars {
|
||||
os.Unsetenv(envVar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigEdgeCases(t *testing.T) {
|
||||
os.Setenv("DB_PASSWORD", "testpassword")
|
||||
os.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
os.Setenv("SMTP_FROM", "test@example.com")
|
||||
os.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
os.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes-only")
|
||||
os.Setenv("SERVER_READ_TIMEOUT", "0")
|
||||
os.Setenv("SERVER_WRITE_TIMEOUT", "0")
|
||||
os.Setenv("SERVER_IDLE_TIMEOUT", "0")
|
||||
|
||||
config, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if config.Server.ReadTimeout != 0 {
|
||||
t.Errorf("Expected ReadTimeout to be 0, got %v", config.Server.ReadTimeout)
|
||||
}
|
||||
|
||||
if config.Server.WriteTimeout != 0 {
|
||||
t.Errorf("Expected WriteTimeout to be 0, got %v", config.Server.WriteTimeout)
|
||||
}
|
||||
|
||||
if config.Server.IdleTimeout != 0 {
|
||||
t.Errorf("Expected IdleTimeout to be 0, got %v", config.Server.IdleTimeout)
|
||||
}
|
||||
|
||||
os.Setenv("SERVER_MAX_HEADER_BYTES", "10485760")
|
||||
|
||||
config, err = Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if config.Server.MaxHeaderBytes != 10485760 {
|
||||
t.Errorf("Expected MaxHeaderBytes to be 10MB, got %d", config.Server.MaxHeaderBytes)
|
||||
}
|
||||
|
||||
envVars := []string{
|
||||
"SERVER_READ_TIMEOUT",
|
||||
"SERVER_WRITE_TIMEOUT",
|
||||
"SERVER_IDLE_TIMEOUT",
|
||||
"SERVER_MAX_HEADER_BYTES",
|
||||
}
|
||||
for _, envVar := range envVars {
|
||||
os.Unsetenv(envVar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSValidation(t *testing.T) {
|
||||
os.Setenv("DB_PASSWORD", "testpassword")
|
||||
os.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
os.Setenv("SMTP_FROM", "test@example.com")
|
||||
os.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
os.Setenv("JWT_SECRET", "this-is-a-very-long-secret-key-for-testing-purposes-only")
|
||||
os.Setenv("SERVER_ENABLE_TLS", "true")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Error("Expected error when TLS is enabled without cert files")
|
||||
}
|
||||
|
||||
if err.Error() != "SERVER_TLS_CERT_FILE is required when SERVER_ENABLE_TLS is true" {
|
||||
t.Errorf("Expected specific error message, got: %v", err)
|
||||
}
|
||||
|
||||
os.Setenv("SERVER_TLS_CERT_FILE", "/path/to/cert.pem")
|
||||
|
||||
_, err = Load()
|
||||
if err == nil {
|
||||
t.Error("Expected error when TLS is enabled without key file")
|
||||
}
|
||||
|
||||
if err.Error() != "SERVER_TLS_KEY_FILE is required when SERVER_ENABLE_TLS is true" {
|
||||
t.Errorf("Expected specific error message, got: %v", err)
|
||||
}
|
||||
|
||||
os.Setenv("SERVER_TLS_KEY_FILE", "/path/to/key.pem")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config with TLS: %v", err)
|
||||
}
|
||||
|
||||
if !cfg.Server.EnableTLS {
|
||||
t.Error("Expected EnableTLS to be true")
|
||||
}
|
||||
|
||||
envVars := []string{
|
||||
"SERVER_ENABLE_TLS",
|
||||
"SERVER_TLS_CERT_FILE",
|
||||
"SERVER_TLS_KEY_FILE",
|
||||
}
|
||||
for _, envVar := range envVars {
|
||||
os.Unsetenv(envVar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBcryptCost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bcryptCost int
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid cost at minimum (10)",
|
||||
bcryptCost: 10,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid cost at maximum (14)",
|
||||
bcryptCost: 14,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid cost in middle (12)",
|
||||
bcryptCost: 12,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "cost too low (9)",
|
||||
bcryptCost: 9,
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at least 10 for security",
|
||||
},
|
||||
{
|
||||
name: "cost too low (5)",
|
||||
bcryptCost: 5,
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at least 10 for security",
|
||||
},
|
||||
{
|
||||
name: "cost too low (0)",
|
||||
bcryptCost: 0,
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at least 10 for security",
|
||||
},
|
||||
{
|
||||
name: "cost too high (15)",
|
||||
bcryptCost: 15,
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at most 14 to avoid performance issues",
|
||||
},
|
||||
{
|
||||
name: "cost too high (20)",
|
||||
bcryptCost: 20,
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at most 14 to avoid performance issues",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
appConfig := AppConfig{
|
||||
BcryptCost: tt.bcryptCost,
|
||||
}
|
||||
err := validateAppConfig(&appConfig)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for BCRYPT_COST %d, got nil", tt.bcryptCost)
|
||||
}
|
||||
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for BCRYPT_COST %d: %v", tt.bcryptCost, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithInvalidBcryptCost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bcryptCost string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "cost too low",
|
||||
bcryptCost: "9",
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at least 10",
|
||||
},
|
||||
{
|
||||
name: "cost too high",
|
||||
bcryptCost: "15",
|
||||
expectError: true,
|
||||
errorMsg: "BCRYPT_COST must be at most 14",
|
||||
},
|
||||
{
|
||||
name: "valid cost",
|
||||
bcryptCost: "12",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "default cost",
|
||||
bcryptCost: "",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
t.Setenv("DB_PASSWORD", "password")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "2525")
|
||||
t.Setenv("SMTP_FROM", "no-reply@example.com")
|
||||
t.Setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
t.Setenv("JWT_SECRET", "this-is-a-very-secure-jwt-secret-key-that-is-long-enough")
|
||||
|
||||
if tt.bcryptCost != "" {
|
||||
t.Setenv("BCRYPT_COST", tt.bcryptCost)
|
||||
} else {
|
||||
os.Unsetenv("BCRYPT_COST")
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("expected error but got nil")
|
||||
}
|
||||
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
|
||||
t.Fatalf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expectedCost := 12
|
||||
if tt.bcryptCost == "" {
|
||||
expectedCost = 10
|
||||
} else {
|
||||
if costInt, err := strconv.Atoi(tt.bcryptCost); err == nil {
|
||||
expectedCost = costInt
|
||||
}
|
||||
}
|
||||
if cfg.App.BcryptCost != expectedCost {
|
||||
t.Fatalf("expected BCRYPT_COST %d, got %d", expectedCost, cfg.App.BcryptCost)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"goyco/internal/config"
|
||||
"goyco/internal/middleware"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func connectDB(cfg *config.Config) (*gorm.DB, error) {
|
||||
dsn := cfg.GetConnectionString()
|
||||
gormLogger := CreateSecureLogger(!cfg.App.Debug)
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func Connect(cfg *config.Config) (*gorm.DB, error) {
|
||||
return connectDB(cfg)
|
||||
}
|
||||
|
||||
func ConnectWithMonitoring(cfg *config.Config, monitor middleware.DBMonitor) (*gorm.DB, error) {
|
||||
db, err := connectDB(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if monitor != nil {
|
||||
monitoringPlugin := NewGormDBMonitor(monitor)
|
||||
if err := db.Use(monitoringPlugin); err != nil {
|
||||
return nil, fmt.Errorf("failed to add monitoring plugin: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func Migrate(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database connection is nil")
|
||||
}
|
||||
|
||||
err := db.AutoMigrate(
|
||||
&User{},
|
||||
&Post{},
|
||||
&Vote{},
|
||||
&AccountDeletionRequest{},
|
||||
&RefreshToken{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Close(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get underlying sql.DB: %w", err)
|
||||
}
|
||||
|
||||
return sqlDB.Close()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"goyco/internal/config"
|
||||
)
|
||||
|
||||
type ConnectionPoolConfig struct {
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
ConnMaxIdleTime time.Duration
|
||||
ConnTimeout time.Duration
|
||||
HealthCheckInterval time.Duration
|
||||
}
|
||||
|
||||
func DefaultConnectionPoolConfig() ConnectionPoolConfig {
|
||||
return ConnectionPoolConfig{
|
||||
MaxOpenConns: 25,
|
||||
MaxIdleConns: 10,
|
||||
ConnMaxLifetime: 5 * time.Minute,
|
||||
ConnMaxIdleTime: 1 * time.Minute,
|
||||
ConnTimeout: 30 * time.Second,
|
||||
HealthCheckInterval: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func ProductionConnectionPoolConfig() ConnectionPoolConfig {
|
||||
return ConnectionPoolConfig{
|
||||
MaxOpenConns: 100,
|
||||
MaxIdleConns: 25,
|
||||
ConnMaxLifetime: 10 * time.Minute,
|
||||
ConnMaxIdleTime: 2 * time.Minute,
|
||||
ConnTimeout: 10 * time.Second,
|
||||
HealthCheckInterval: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func HighTrafficConnectionPoolConfig() ConnectionPoolConfig {
|
||||
return ConnectionPoolConfig{
|
||||
MaxOpenConns: 200,
|
||||
MaxIdleConns: 50,
|
||||
ConnMaxLifetime: 15 * time.Minute,
|
||||
ConnMaxIdleTime: 5 * time.Minute,
|
||||
ConnTimeout: 5 * time.Second,
|
||||
HealthCheckInterval: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionPoolManager struct {
|
||||
db *gorm.DB
|
||||
sqlDB *sql.DB
|
||||
config ConnectionPoolConfig
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewConnectionPoolManager(cfg *config.Config, poolConfig ConnectionPoolConfig) (*ConnectionPoolManager, error) {
|
||||
dsn := cfg.GetConnectionString()
|
||||
|
||||
secureLogger := CreateSecureLogger(!cfg.App.Debug)
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: secureLogger,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err)
|
||||
}
|
||||
|
||||
sqlDB.SetMaxOpenConns(poolConfig.MaxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(poolConfig.MaxIdleConns)
|
||||
sqlDB.SetConnMaxLifetime(poolConfig.ConnMaxLifetime)
|
||||
sqlDB.SetConnMaxIdleTime(poolConfig.ConnMaxIdleTime)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), poolConfig.ConnTimeout)
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
managerCtx, managerCancel := context.WithCancel(context.Background())
|
||||
|
||||
manager := &ConnectionPoolManager{
|
||||
db: db,
|
||||
sqlDB: sqlDB,
|
||||
config: poolConfig,
|
||||
ctx: managerCtx,
|
||||
cancel: managerCancel,
|
||||
}
|
||||
|
||||
go manager.startHealthCheck()
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (m *ConnectionPoolManager) GetDB() *gorm.DB {
|
||||
return m.db
|
||||
}
|
||||
|
||||
func (m *ConnectionPoolManager) GetSQLDB() *sql.DB {
|
||||
return m.sqlDB
|
||||
}
|
||||
|
||||
func (m *ConnectionPoolManager) GetPoolStats() sql.DBStats {
|
||||
return m.sqlDB.Stats()
|
||||
}
|
||||
|
||||
func (m *ConnectionPoolManager) startHealthCheck() {
|
||||
ticker := time.NewTicker(m.config.HealthCheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.performHealthCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ConnectionPoolManager) performHealthCheck() {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, m.config.ConnTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := m.sqlDB.PingContext(ctx); err != nil {
|
||||
log.Printf("Database health check failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ConnectionPoolManager) Close() error {
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
|
||||
if m.sqlDB != nil {
|
||||
return m.sqlDB.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ConnectWithPool(cfg *config.Config) (*ConnectionPoolManager, error) {
|
||||
var poolConfig ConnectionPoolConfig
|
||||
|
||||
if cfg.App.Debug {
|
||||
poolConfig = DefaultConnectionPoolConfig()
|
||||
} else {
|
||||
poolConfig = ProductionConnectionPoolConfig()
|
||||
}
|
||||
|
||||
if cfg.App.BaseURL != "" && !cfg.App.Debug {
|
||||
poolConfig = HighTrafficConnectionPoolConfig()
|
||||
}
|
||||
|
||||
return NewConnectionPoolManager(cfg, poolConfig)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/config"
|
||||
)
|
||||
|
||||
func TestConnectionPoolConfig(t *testing.T) {
|
||||
t.Run("default_config", func(t *testing.T) {
|
||||
config := DefaultConnectionPoolConfig()
|
||||
|
||||
if config.MaxOpenConns <= 0 {
|
||||
t.Error("MaxOpenConns should be positive")
|
||||
}
|
||||
if config.MaxIdleConns <= 0 {
|
||||
t.Error("MaxIdleConns should be positive")
|
||||
}
|
||||
if config.ConnMaxLifetime <= 0 {
|
||||
t.Error("ConnMaxLifetime should be positive")
|
||||
}
|
||||
if config.ConnMaxIdleTime <= 0 {
|
||||
t.Error("ConnMaxIdleTime should be positive")
|
||||
}
|
||||
if config.ConnTimeout <= 0 {
|
||||
t.Error("ConnTimeout should be positive")
|
||||
}
|
||||
if config.HealthCheckInterval <= 0 {
|
||||
t.Error("HealthCheckInterval should be positive")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("production_config", func(t *testing.T) {
|
||||
config := ProductionConnectionPoolConfig()
|
||||
|
||||
if config.MaxOpenConns < 50 {
|
||||
t.Error("Production MaxOpenConns should be higher")
|
||||
}
|
||||
if config.MaxIdleConns < 10 {
|
||||
t.Error("Production MaxIdleConns should be higher")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("high_traffic_config", func(t *testing.T) {
|
||||
config := HighTrafficConnectionPoolConfig()
|
||||
|
||||
if config.MaxOpenConns < 100 {
|
||||
t.Error("High traffic MaxOpenConns should be very high")
|
||||
}
|
||||
if config.MaxIdleConns < 25 {
|
||||
t.Error("High traffic MaxIdleConns should be high")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConnectionPoolManager_Stats(t *testing.T) {
|
||||
|
||||
t.Run("config_validation", func(t *testing.T) {
|
||||
config := DefaultConnectionPoolConfig()
|
||||
|
||||
if config.MaxOpenConns < config.MaxIdleConns {
|
||||
t.Error("MaxOpenConns should be >= MaxIdleConns")
|
||||
}
|
||||
|
||||
if config.ConnMaxLifetime < config.ConnMaxIdleTime {
|
||||
t.Error("ConnMaxLifetime should be >= ConnMaxIdleTime")
|
||||
}
|
||||
|
||||
if config.ConnTimeout > 60*time.Second {
|
||||
t.Error("ConnTimeout should be reasonable")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConnectionPoolConfig_Values(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config ConnectionPoolConfig
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
config: DefaultConnectionPoolConfig(),
|
||||
},
|
||||
{
|
||||
name: "production",
|
||||
config: ProductionConnectionPoolConfig(),
|
||||
},
|
||||
{
|
||||
name: "high_traffic",
|
||||
config: HighTrafficConnectionPoolConfig(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := tt.config
|
||||
|
||||
if config.MaxOpenConns <= 0 {
|
||||
t.Errorf("MaxOpenConns should be positive, got %d", config.MaxOpenConns)
|
||||
}
|
||||
if config.MaxIdleConns <= 0 {
|
||||
t.Errorf("MaxIdleConns should be positive, got %d", config.MaxIdleConns)
|
||||
}
|
||||
if config.ConnMaxLifetime <= 0 {
|
||||
t.Errorf("ConnMaxLifetime should be positive, got %v", config.ConnMaxLifetime)
|
||||
}
|
||||
if config.ConnMaxIdleTime <= 0 {
|
||||
t.Errorf("ConnMaxIdleTime should be positive, got %v", config.ConnMaxIdleTime)
|
||||
}
|
||||
if config.ConnTimeout <= 0 {
|
||||
t.Errorf("ConnTimeout should be positive, got %v", config.ConnTimeout)
|
||||
}
|
||||
if config.HealthCheckInterval <= 0 {
|
||||
t.Errorf("HealthCheckInterval should be positive, got %v", config.HealthCheckInterval)
|
||||
}
|
||||
|
||||
if config.MaxOpenConns < config.MaxIdleConns {
|
||||
t.Errorf("MaxOpenConns (%d) should be >= MaxIdleConns (%d)", config.MaxOpenConns, config.MaxIdleConns)
|
||||
}
|
||||
|
||||
if config.ConnMaxLifetime < config.ConnMaxIdleTime {
|
||||
t.Errorf("ConnMaxLifetime (%v) should be >= ConnMaxIdleTime (%v)", config.ConnMaxLifetime, config.ConnMaxIdleTime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConnectionPoolManager(t *testing.T) {
|
||||
t.Run("invalid_database_config", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "invalid-host",
|
||||
Port: "9999",
|
||||
User: "invalid",
|
||||
Password: "invalid",
|
||||
Name: "invalid",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
App: config.AppConfig{
|
||||
Debug: true,
|
||||
},
|
||||
}
|
||||
|
||||
poolConfig := DefaultConnectionPoolConfig()
|
||||
manager, err := NewConnectionPoolManager(cfg, poolConfig)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error with invalid database config")
|
||||
}
|
||||
if manager != nil {
|
||||
t.Error("Expected nil manager with invalid database config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to connect to database") {
|
||||
t.Errorf("Expected connection error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConnectionPoolManager_Methods(t *testing.T) {
|
||||
t.Run("get_db_methods", func(t *testing.T) {
|
||||
|
||||
manager := &ConnectionPoolManager{
|
||||
db: nil,
|
||||
sqlDB: nil,
|
||||
}
|
||||
|
||||
if manager.GetDB() != nil {
|
||||
t.Error("Expected nil DB from uninitialized manager")
|
||||
}
|
||||
|
||||
if manager.GetSQLDB() != nil {
|
||||
t.Error("Expected nil SQLDB from uninitialized manager")
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func TestConnectWithPool(t *testing.T) {
|
||||
t.Run("debug_mode_config", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: "5432",
|
||||
User: "test",
|
||||
Password: "test",
|
||||
Name: "test_db",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
App: config.AppConfig{
|
||||
Debug: true,
|
||||
},
|
||||
}
|
||||
|
||||
manager, err := ConnectWithPool(cfg)
|
||||
if err == nil {
|
||||
t.Error("Expected error with invalid database config")
|
||||
}
|
||||
if manager != nil {
|
||||
t.Error("Expected nil manager with invalid database config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("production_mode_config", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: "5432",
|
||||
User: "test",
|
||||
Password: "test",
|
||||
Name: "test_db",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
App: config.AppConfig{
|
||||
Debug: false,
|
||||
},
|
||||
}
|
||||
|
||||
manager, err := ConnectWithPool(cfg)
|
||||
if err == nil {
|
||||
t.Error("Expected error with invalid database config")
|
||||
}
|
||||
if manager != nil {
|
||||
t.Error("Expected nil manager with invalid database config")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("high_traffic_config", func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: "5432",
|
||||
User: "test",
|
||||
Password: "test",
|
||||
Name: "test_db",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
App: config.AppConfig{
|
||||
Debug: false,
|
||||
BaseURL: "https://example.com",
|
||||
},
|
||||
}
|
||||
|
||||
manager, err := ConnectWithPool(cfg)
|
||||
if err == nil {
|
||||
t.Error("Expected error with invalid database config")
|
||||
}
|
||||
if manager != nil {
|
||||
t.Error("Expected nil manager with invalid database config")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"goyco/internal/config"
|
||||
"goyco/internal/middleware"
|
||||
)
|
||||
|
||||
func TestConnectReturnsErrorWhenUnableToReachDatabase(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: "1",
|
||||
User: "postgres",
|
||||
Password: "password",
|
||||
Name: "goyco_test",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
}
|
||||
_, err := Connect(cfg)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatalf("expected connection error but got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to connect to database") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("connection test timed out after 5 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateFailsWhenDBNil(t *testing.T) {
|
||||
err := Migrate(nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when DB is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateCreatesTables(t *testing.T) {
|
||||
dbName := "file:memdb_" + t.Name() + "?mode=memory&cache=private"
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite in-memory database: %v", err)
|
||||
}
|
||||
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatalf("expected migrations to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
migrator := db.Migrator()
|
||||
|
||||
models := []any{&User{}, &Post{}, &Vote{}}
|
||||
for _, model := range models {
|
||||
if !migrator.HasTable(model) {
|
||||
t.Fatalf("expected table for %T to exist after migration", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseReturnsNilWhenDBNil(t *testing.T) {
|
||||
if err := Close(nil); err != nil {
|
||||
t.Fatalf("expected nil error when DB is nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseClosesUnderlyingConnection(t *testing.T) {
|
||||
dbName := "file:memdb_" + t.Name() + "?mode=memory&cache=private"
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite in-memory database: %v", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get sql.DB: %v", err)
|
||||
}
|
||||
|
||||
if err := Close(db); err != nil {
|
||||
t.Fatalf("expected close to succeed, got %v", err)
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err == nil {
|
||||
t.Fatalf("expected ping on closed connection to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectWithMonitoring(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: "5432",
|
||||
User: "test",
|
||||
Password: "test",
|
||||
Name: "test_db",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
App: config.AppConfig{
|
||||
Debug: true,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ConnectWithMonitoring(cfg, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected connection error with invalid database config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to connect to database") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectWithMonitoringWithValidMonitor(t *testing.T) {
|
||||
mockMonitor := middleware.NewInMemoryDBMonitor()
|
||||
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: "5432",
|
||||
User: "test",
|
||||
Password: "test",
|
||||
Name: "test_db",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
App: config.AppConfig{
|
||||
Debug: true,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ConnectWithMonitoring(cfg, mockMonitor)
|
||||
if err == nil {
|
||||
t.Fatalf("expected connection error with invalid database config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to connect to database") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Title string `gorm:"not null"`
|
||||
URL string `gorm:"uniqueIndex"`
|
||||
Content string
|
||||
AuthorID *uint
|
||||
AuthorName string
|
||||
Author User `gorm:"foreignKey:AuthorID;constraint:OnDelete:CASCADE"`
|
||||
UpVotes int `gorm:"default:0"`
|
||||
DownVotes int `gorm:"default:0"`
|
||||
Score int `gorm:"default:0"`
|
||||
Votes []Vote `gorm:"foreignKey:PostID;constraint:OnDelete:CASCADE"`
|
||||
CurrentVote VoteType `gorm:"-"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"uniqueIndex;not null"`
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
Password string `gorm:"not null"`
|
||||
EmailVerified bool `gorm:"default:false;not null"`
|
||||
EmailVerifiedAt *time.Time
|
||||
EmailVerificationToken string `gorm:"index"`
|
||||
EmailVerificationSentAt *time.Time
|
||||
PasswordResetToken string `gorm:"index"`
|
||||
PasswordResetSentAt *time.Time
|
||||
PasswordResetExpiresAt *time.Time
|
||||
Locked bool `gorm:"default:false"`
|
||||
SessionVersion uint `gorm:"default:1;not null"`
|
||||
RefreshTokens []RefreshToken `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||
Posts []Post `gorm:"foreignKey:AuthorID"`
|
||||
Votes []Vote `gorm:"foreignKey:UserID"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
|
||||
type RefreshToken struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"not null;index"`
|
||||
User User `gorm:"constraint:OnDelete:CASCADE"`
|
||||
TokenHash string `gorm:"uniqueIndex;not null"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
|
||||
type AccountDeletionRequest struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"uniqueIndex"`
|
||||
User User `gorm:"constraint:OnDelete:CASCADE"`
|
||||
TokenHash string `gorm:"uniqueIndex;not null"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Vote struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID *uint `gorm:"uniqueIndex:idx_user_post_vote,where:deleted_at IS NULL AND user_id IS NOT NULL"`
|
||||
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||
PostID uint `gorm:"not null;uniqueIndex:idx_user_post_vote,where:deleted_at IS NULL AND user_id IS NOT NULL;uniqueIndex:idx_hash_post_vote,where:deleted_at IS NULL AND vote_hash IS NOT NULL"`
|
||||
Post Post `gorm:"foreignKey:PostID;constraint:OnDelete:CASCADE"`
|
||||
Type VoteType `gorm:"not null"`
|
||||
VoteHash *string `gorm:"uniqueIndex:idx_hash_post_vote,where:deleted_at IS NULL AND vote_hash IS NOT NULL"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
|
||||
type VoteType string
|
||||
|
||||
const (
|
||||
VoteUp VoteType = "up"
|
||||
VoteDown VoteType = "down"
|
||||
VoteNone VoteType = "none"
|
||||
)
|
||||
@@ -0,0 +1,603 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbName := "file:memdb_" + t.Name() + "?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 test database: %v", err)
|
||||
}
|
||||
err = db.AutoMigrate(
|
||||
&User{},
|
||||
&Post{},
|
||||
&Vote{},
|
||||
&AccountDeletionRequest{},
|
||||
&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)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to access SQL DB: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
return db
|
||||
}
|
||||
|
||||
func createTestUser(t *testing.T, db *gorm.DB) *User {
|
||||
t.Helper()
|
||||
|
||||
uniqueID := time.Now().UnixNano()
|
||||
user := &User{
|
||||
Username: fmt.Sprintf("testuser%d", uniqueID),
|
||||
Email: fmt.Sprintf("test%d@example.com", uniqueID),
|
||||
Password: "hashedpassword123",
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("Failed to create test user: %v", err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func createTestPost(t *testing.T, db *gorm.DB, authorID uint) *Post {
|
||||
t.Helper()
|
||||
post := &Post{
|
||||
Title: "Test Post " + t.Name(),
|
||||
URL: "https://example.com/test" + t.Name(),
|
||||
Content: "Test content",
|
||||
AuthorID: &authorID,
|
||||
}
|
||||
if err := db.Create(post).Error; err != nil {
|
||||
t.Fatalf("Failed to create test post: %v", err)
|
||||
}
|
||||
return post
|
||||
}
|
||||
|
||||
func TestUser_Model(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("create_user", func(t *testing.T) {
|
||||
user := &User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
if user.ID == 0 {
|
||||
t.Error("Expected user ID to be set")
|
||||
}
|
||||
|
||||
if user.CreatedAt.IsZero() {
|
||||
t.Error("Expected CreatedAt to be set")
|
||||
}
|
||||
|
||||
if user.UpdatedAt.IsZero() {
|
||||
t.Error("Expected UpdatedAt to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user_constraints", func(t *testing.T) {
|
||||
|
||||
user1 := &User{
|
||||
Username: "duplicate",
|
||||
Email: "user1@example.com",
|
||||
Password: "hashedpassword",
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
user2 := &User{
|
||||
Username: "duplicate",
|
||||
Email: "user2@example.com",
|
||||
Password: "hashedpassword",
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if err := db.Create(user1).Error; err != nil {
|
||||
t.Fatalf("Failed to create first user: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(user2).Error; err == nil {
|
||||
t.Error("Expected error when creating user with duplicate username")
|
||||
}
|
||||
|
||||
user3 := &User{
|
||||
Username: "unique",
|
||||
Email: "user1@example.com",
|
||||
Password: "hashedpassword",
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if err := db.Create(user3).Error; err == nil {
|
||||
t.Error("Expected error when creating user with duplicate email")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user_relationships", func(t *testing.T) {
|
||||
user := &User{
|
||||
Username: "author",
|
||||
Email: "author@example.com",
|
||||
Password: "hashedpassword",
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
post1 := &Post{
|
||||
Title: "Post 1",
|
||||
URL: "https://example.com/1",
|
||||
Content: "Content 1",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
|
||||
post2 := &Post{
|
||||
Title: "Post 2",
|
||||
URL: "https://example.com/2",
|
||||
Content: "Content 2",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
|
||||
if err := db.Create(post1).Error; err != nil {
|
||||
t.Fatalf("Failed to create post 1: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(post2).Error; err != nil {
|
||||
t.Fatalf("Failed to create post 2: %v", err)
|
||||
}
|
||||
|
||||
var foundUser User
|
||||
if err := db.Preload("Posts").First(&foundUser, user.ID).Error; err != nil {
|
||||
t.Fatalf("Failed to load user with posts: %v", err)
|
||||
}
|
||||
|
||||
if len(foundUser.Posts) != 2 {
|
||||
t.Errorf("Expected 2 posts, got %d", len(foundUser.Posts))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPost_Model(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("create_post", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
|
||||
post := &Post{
|
||||
Title: "Test Post",
|
||||
URL: "https://example.com/test",
|
||||
Content: "Test content",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
|
||||
if err := db.Create(post).Error; err != nil {
|
||||
t.Fatalf("Failed to create post: %v", err)
|
||||
}
|
||||
|
||||
if post.ID == 0 {
|
||||
t.Error("Expected post ID to be set")
|
||||
}
|
||||
|
||||
if post.CreatedAt.IsZero() {
|
||||
t.Error("Expected CreatedAt to be set")
|
||||
}
|
||||
|
||||
if post.UpdatedAt.IsZero() {
|
||||
t.Error("Expected UpdatedAt to be set")
|
||||
}
|
||||
|
||||
if post.UpVotes != 0 {
|
||||
t.Error("Expected UpVotes to be 0 by default")
|
||||
}
|
||||
|
||||
if post.DownVotes != 0 {
|
||||
t.Error("Expected DownVotes to be 0 by default")
|
||||
}
|
||||
|
||||
if post.Score != 0 {
|
||||
t.Error("Expected Score to be 0 by default")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post_constraints", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
|
||||
post1 := &Post{
|
||||
Title: "Post 1",
|
||||
URL: "https://example.com/unique",
|
||||
Content: "Content 1",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
|
||||
post2 := &Post{
|
||||
Title: "Post 2",
|
||||
URL: "https://example.com/unique",
|
||||
Content: "Content 2",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
|
||||
if err := db.Create(post1).Error; err != nil {
|
||||
t.Fatalf("Failed to create first post: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(post2).Error; err == nil {
|
||||
t.Error("Expected error when creating post with duplicate URL")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post_relationships", func(t *testing.T) {
|
||||
user1 := createTestUser(t, db)
|
||||
user2 := createTestUser(t, db)
|
||||
|
||||
post := createTestPost(t, db, user1.ID)
|
||||
|
||||
vote1 := &Vote{
|
||||
UserID: &user1.ID,
|
||||
PostID: post.ID,
|
||||
Type: VoteUp,
|
||||
}
|
||||
|
||||
vote2 := &Vote{
|
||||
UserID: &user2.ID,
|
||||
PostID: post.ID,
|
||||
Type: VoteDown,
|
||||
}
|
||||
|
||||
if err := db.Create(vote1).Error; err != nil {
|
||||
t.Fatalf("Failed to create vote 1: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(vote2).Error; err != nil {
|
||||
t.Fatalf("Failed to create vote 2: %v", err)
|
||||
}
|
||||
|
||||
var foundPost Post
|
||||
if err := db.Preload("Votes").First(&foundPost, post.ID).Error; err != nil {
|
||||
t.Fatalf("Failed to load post with votes: %v", err)
|
||||
}
|
||||
|
||||
if len(foundPost.Votes) != 2 {
|
||||
t.Errorf("Expected 2 votes, got %d", len(foundPost.Votes))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVote_Model(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("create_vote", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
post := createTestPost(t, db, user.ID)
|
||||
|
||||
vote := &Vote{
|
||||
UserID: &user.ID,
|
||||
PostID: post.ID,
|
||||
Type: VoteUp,
|
||||
}
|
||||
|
||||
if err := db.Create(vote).Error; err != nil {
|
||||
t.Fatalf("Failed to create vote: %v", err)
|
||||
}
|
||||
|
||||
if vote.ID == 0 {
|
||||
t.Error("Expected vote ID to be set")
|
||||
}
|
||||
|
||||
if vote.CreatedAt.IsZero() {
|
||||
t.Error("Expected CreatedAt to be set")
|
||||
}
|
||||
|
||||
if vote.UpdatedAt.IsZero() {
|
||||
t.Error("Expected UpdatedAt to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vote_constraints", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
post := createTestPost(t, db, user.ID)
|
||||
|
||||
vote1 := &Vote{
|
||||
UserID: &user.ID,
|
||||
PostID: post.ID,
|
||||
Type: VoteUp,
|
||||
}
|
||||
|
||||
vote2 := &Vote{
|
||||
UserID: &user.ID,
|
||||
PostID: post.ID,
|
||||
Type: VoteDown,
|
||||
}
|
||||
|
||||
if err := db.Create(vote1).Error; err != nil {
|
||||
t.Fatalf("Failed to create first vote: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(vote2).Error; err == nil {
|
||||
t.Error("Expected error when creating vote with duplicate user-post combination")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vote_types", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
|
||||
voteTypes := []VoteType{VoteUp, VoteDown, VoteNone}
|
||||
|
||||
for i, voteType := range voteTypes {
|
||||
|
||||
post := &Post{
|
||||
Title: "Test Post " + string(rune(i)),
|
||||
URL: "https://example.com/test" + string(rune(i)),
|
||||
Content: "Test content",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
|
||||
if err := db.Create(post).Error; err != nil {
|
||||
t.Fatalf("Failed to create post %d: %v", i, err)
|
||||
}
|
||||
|
||||
vote := &Vote{
|
||||
UserID: &user.ID,
|
||||
PostID: post.ID,
|
||||
Type: voteType,
|
||||
}
|
||||
|
||||
if err := db.Create(vote).Error; err != nil {
|
||||
t.Fatalf("Failed to create vote with type %s: %v", voteType, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vote_relationships", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
post := createTestPost(t, db, user.ID)
|
||||
|
||||
vote := &Vote{
|
||||
UserID: &user.ID,
|
||||
PostID: post.ID,
|
||||
Type: VoteUp,
|
||||
}
|
||||
|
||||
if err := db.Create(vote).Error; err != nil {
|
||||
t.Fatalf("Failed to create vote: %v", err)
|
||||
}
|
||||
|
||||
var foundVote Vote
|
||||
if err := db.Preload("User").Preload("Post").First(&foundVote, vote.ID).Error; err != nil {
|
||||
t.Fatalf("Failed to load vote with relationships: %v", err)
|
||||
}
|
||||
|
||||
if foundVote.User.ID != user.ID {
|
||||
t.Error("Expected vote to be associated with correct user")
|
||||
}
|
||||
|
||||
if foundVote.Post.ID != post.ID {
|
||||
t.Error("Expected vote to be associated with correct post")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefreshToken_Model(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("create_refresh_token", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
|
||||
token := &RefreshToken{
|
||||
UserID: user.ID,
|
||||
TokenHash: "hashedtoken123",
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := db.Create(token).Error; err != nil {
|
||||
t.Fatalf("Failed to create refresh token: %v", err)
|
||||
}
|
||||
|
||||
if token.ID == 0 {
|
||||
t.Error("Expected token ID to be set")
|
||||
}
|
||||
|
||||
if token.CreatedAt.IsZero() {
|
||||
t.Error("Expected CreatedAt to be set")
|
||||
}
|
||||
|
||||
if token.UpdatedAt.IsZero() {
|
||||
t.Error("Expected UpdatedAt to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_token_constraints", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
|
||||
token1 := &RefreshToken{
|
||||
UserID: user.ID,
|
||||
TokenHash: "uniquehash",
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
token2 := &RefreshToken{
|
||||
UserID: user.ID,
|
||||
TokenHash: "uniquehash",
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := db.Create(token1).Error; err != nil {
|
||||
t.Fatalf("Failed to create first token: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(token2).Error; err == nil {
|
||||
t.Error("Expected error when creating token with duplicate hash")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccountDeletionRequest_Model(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("create_account_deletion_request", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
|
||||
request := &AccountDeletionRequest{
|
||||
UserID: user.ID,
|
||||
TokenHash: "deletiontoken123",
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := db.Create(request).Error; err != nil {
|
||||
t.Fatalf("Failed to create account deletion request: %v", err)
|
||||
}
|
||||
|
||||
if request.ID == 0 {
|
||||
t.Error("Expected request ID to be set")
|
||||
}
|
||||
|
||||
if request.CreatedAt.IsZero() {
|
||||
t.Error("Expected CreatedAt to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("account_deletion_request_constraints", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
|
||||
request1 := &AccountDeletionRequest{
|
||||
UserID: user.ID,
|
||||
TokenHash: "token1",
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
request2 := &AccountDeletionRequest{
|
||||
UserID: user.ID,
|
||||
TokenHash: "token2",
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := db.Create(request1).Error; err != nil {
|
||||
t.Fatalf("Failed to create first request: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(request2).Error; err == nil {
|
||||
t.Error("Expected error when creating request with duplicate user")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVoteType_Constants(t *testing.T) {
|
||||
t.Run("vote_type_constants", func(t *testing.T) {
|
||||
if VoteUp != "up" {
|
||||
t.Errorf("Expected VoteUp to be 'up', got '%s'", VoteUp)
|
||||
}
|
||||
|
||||
if VoteDown != "down" {
|
||||
t.Errorf("Expected VoteDown to be 'down', got '%s'", VoteDown)
|
||||
}
|
||||
|
||||
if VoteNone != "none" {
|
||||
t.Errorf("Expected VoteNone to be 'none', got '%s'", VoteNone)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestModel_SoftDelete(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("user_soft_delete", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
|
||||
if err := db.Delete(user).Error; err != nil {
|
||||
t.Fatalf("Failed to soft delete user: %v", err)
|
||||
}
|
||||
|
||||
var foundUser User
|
||||
if err := db.First(&foundUser, user.ID).Error; err == nil {
|
||||
t.Error("Expected user to be soft deleted")
|
||||
}
|
||||
|
||||
if err := db.Unscoped().First(&foundUser, user.ID).Error; err != nil {
|
||||
t.Fatalf("Expected to find soft deleted user with Unscoped: %v", err)
|
||||
}
|
||||
|
||||
if foundUser.DeletedAt.Time.IsZero() {
|
||||
t.Error("Expected DeletedAt to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post_soft_delete", func(t *testing.T) {
|
||||
user := createTestUser(t, db)
|
||||
post := createTestPost(t, db, user.ID)
|
||||
|
||||
if err := db.Delete(post).Error; err != nil {
|
||||
t.Fatalf("Failed to soft delete post: %v", err)
|
||||
}
|
||||
|
||||
var foundPost Post
|
||||
if err := db.First(&foundPost, post.ID).Error; err == nil {
|
||||
t.Error("Expected post to be soft deleted")
|
||||
}
|
||||
|
||||
if err := db.Unscoped().First(&foundPost, post.ID).Error; err != nil {
|
||||
t.Fatalf("Expected to find soft deleted post with Unscoped: %v", err)
|
||||
}
|
||||
|
||||
if foundPost.DeletedAt.Time.IsZero() {
|
||||
t.Error("Expected DeletedAt to be set")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"goyco/internal/middleware"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const gormOperationStartKey contextKey = "gorm_operation_start"
|
||||
|
||||
type GormDBMonitor struct {
|
||||
monitor middleware.DBMonitor
|
||||
}
|
||||
|
||||
func NewGormDBMonitor(monitor middleware.DBMonitor) *GormDBMonitor {
|
||||
return &GormDBMonitor{
|
||||
monitor: monitor,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) Name() string {
|
||||
return "db_monitor"
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) Initialize(db *gorm.DB) error {
|
||||
|
||||
db.Callback().Create().Before("gorm:create").Register("db_monitor:before_create", g.beforeCreate)
|
||||
db.Callback().Create().After("gorm:create").Register("db_monitor:after_create", g.afterCreate)
|
||||
|
||||
db.Callback().Query().Before("gorm:query").Register("db_monitor:before_query", g.beforeQuery)
|
||||
db.Callback().Query().After("gorm:query").Register("db_monitor:after_query", g.afterQuery)
|
||||
|
||||
db.Callback().Update().Before("gorm:update").Register("db_monitor:before_update", g.beforeUpdate)
|
||||
db.Callback().Update().After("gorm:update").Register("db_monitor:after_update", g.afterUpdate)
|
||||
|
||||
db.Callback().Delete().Before("gorm:delete").Register("db_monitor:before_delete", g.beforeDelete)
|
||||
db.Callback().Delete().After("gorm:delete").Register("db_monitor:after_delete", g.afterDelete)
|
||||
|
||||
db.Callback().Row().Before("gorm:row").Register("db_monitor:before_row", g.beforeRow)
|
||||
db.Callback().Row().After("gorm:row").Register("db_monitor:after_row", g.afterRow)
|
||||
|
||||
db.Callback().Raw().Before("gorm:raw").Register("db_monitor:before_raw", g.beforeRaw)
|
||||
db.Callback().Raw().After("gorm:raw").Register("db_monitor:after_raw", g.afterRaw)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) beforeCreate(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(db.Statement.Context, gormOperationStartKey, time.Now())
|
||||
db.Statement.Context = ctx
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) afterCreate(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
g.logOperation(db, "CREATE")
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) beforeQuery(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(db.Statement.Context, gormOperationStartKey, time.Now())
|
||||
db.Statement.Context = ctx
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) afterQuery(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
g.logOperation(db, "SELECT")
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) beforeUpdate(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(db.Statement.Context, gormOperationStartKey, time.Now())
|
||||
db.Statement.Context = ctx
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) afterUpdate(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
g.logOperation(db, "UPDATE")
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) beforeDelete(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(db.Statement.Context, gormOperationStartKey, time.Now())
|
||||
db.Statement.Context = ctx
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) afterDelete(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
g.logOperation(db, "DELETE")
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) beforeRow(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(db.Statement.Context, gormOperationStartKey, time.Now())
|
||||
db.Statement.Context = ctx
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) afterRow(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
g.logOperation(db, "ROW")
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) beforeRaw(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(db.Statement.Context, gormOperationStartKey, time.Now())
|
||||
db.Statement.Context = ctx
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) afterRaw(db *gorm.DB) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
g.logOperation(db, "RAW")
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) logOperation(db *gorm.DB, operation string) {
|
||||
if g.monitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
startTime, ok := db.Statement.Context.Value(gormOperationStartKey).(time.Time)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
|
||||
query := g.buildQueryString(db, operation)
|
||||
|
||||
g.monitor.LogQuery(query, duration, db.Error)
|
||||
}
|
||||
|
||||
func (g *GormDBMonitor) buildQueryString(db *gorm.DB, operation string) string {
|
||||
if db.Statement.SQL.String() != "" {
|
||||
return db.Statement.SQL.String()
|
||||
}
|
||||
|
||||
query := operation
|
||||
|
||||
if db.Statement.Table != "" {
|
||||
query += " FROM " + db.Statement.Table
|
||||
}
|
||||
|
||||
if db.Statement.Model != nil {
|
||||
|
||||
if stmt := db.Statement; stmt.Schema != nil {
|
||||
query = operation + " " + stmt.Schema.Table
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"goyco/internal/middleware"
|
||||
)
|
||||
|
||||
func TestNewGormDBMonitor(t *testing.T) {
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
gormMonitor := NewGormDBMonitor(monitor)
|
||||
|
||||
if gormMonitor == nil {
|
||||
t.Fatal("Expected non-nil GormDBMonitor")
|
||||
}
|
||||
|
||||
if gormMonitor.monitor != monitor {
|
||||
t.Error("Expected monitor to be set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGormDBMonitor_Name(t *testing.T) {
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
gormMonitor := NewGormDBMonitor(monitor)
|
||||
|
||||
if gormMonitor.Name() != "db_monitor" {
|
||||
t.Errorf("Expected name 'db_monitor', got '%s'", gormMonitor.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGormDBMonitor_Initialize(t *testing.T) {
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
gormMonitor := NewGormDBMonitor(monitor)
|
||||
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
err := gormMonitor.Initialize(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected Initialize to succeed, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGormDBMonitor_InitializeWithNilMonitor(t *testing.T) {
|
||||
gormMonitor := NewGormDBMonitor(nil)
|
||||
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
err := gormMonitor.Initialize(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected Initialize to succeed with nil monitor, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGormDBMonitor_Callbacks(t *testing.T) {
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
gormMonitor := NewGormDBMonitor(monitor)
|
||||
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
err := gormMonitor.Initialize(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize plugin: %v", err)
|
||||
}
|
||||
|
||||
user := &User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
var foundUser User
|
||||
if err := db.First(&foundUser, user.ID).Error; err != nil {
|
||||
t.Fatalf("Failed to find user: %v", err)
|
||||
}
|
||||
|
||||
foundUser.Username = "updateduser"
|
||||
if err := db.Save(&foundUser).Error; err != nil {
|
||||
t.Fatalf("Failed to update user: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Delete(&foundUser).Error; err != nil {
|
||||
t.Fatalf("Failed to delete user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGormDBMonitor_CallbacksWithNilMonitor(t *testing.T) {
|
||||
gormMonitor := NewGormDBMonitor(nil)
|
||||
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
err := gormMonitor.Initialize(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize plugin: %v", err)
|
||||
}
|
||||
|
||||
user := &User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGormDBMonitor_BuildQueryString(t *testing.T) {
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
gormMonitor := NewGormDBMonitor(monitor)
|
||||
|
||||
db := newTestDB(t)
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
err := gormMonitor.Initialize(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize plugin: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
table string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "create_operation",
|
||||
operation: "CREATE",
|
||||
table: "users",
|
||||
expected: "CREATE FROM users",
|
||||
},
|
||||
{
|
||||
name: "select_operation",
|
||||
operation: "SELECT",
|
||||
table: "posts",
|
||||
expected: "SELECT FROM posts",
|
||||
},
|
||||
{
|
||||
name: "update_operation",
|
||||
operation: "UPDATE",
|
||||
table: "votes",
|
||||
expected: "UPDATE FROM votes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
stmt := &gorm.Statement{
|
||||
Table: tt.table,
|
||||
}
|
||||
|
||||
mockDB := &gorm.DB{
|
||||
Statement: stmt,
|
||||
}
|
||||
|
||||
result := gormMonitor.buildQueryString(mockDB, tt.operation)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGormDBMonitor_LogOperation(t *testing.T) {
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
gormMonitor := NewGormDBMonitor(monitor)
|
||||
|
||||
startTime := time.Now()
|
||||
ctx := context.WithValue(context.Background(), gormOperationStartKey, startTime)
|
||||
|
||||
stmt := &gorm.Statement{
|
||||
Context: ctx,
|
||||
Table: "users",
|
||||
}
|
||||
|
||||
mockDB := &gorm.DB{
|
||||
Statement: stmt,
|
||||
}
|
||||
|
||||
gormMonitor.logOperation(mockDB, "CREATE")
|
||||
|
||||
gormMonitor.monitor = nil
|
||||
gormMonitor.logOperation(mockDB, "CREATE")
|
||||
}
|
||||
|
||||
func TestGormDBMonitor_LogOperationWithoutStartTime(t *testing.T) {
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
gormMonitor := NewGormDBMonitor(monitor)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
stmt := &gorm.Statement{
|
||||
Context: ctx,
|
||||
Table: "users",
|
||||
}
|
||||
|
||||
mockDB := &gorm.DB{
|
||||
Statement: stmt,
|
||||
}
|
||||
|
||||
gormMonitor.logOperation(mockDB, "CREATE")
|
||||
}
|
||||
|
||||
func TestGormDBMonitor_AllCallbackMethods(t *testing.T) {
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
gormMonitor := NewGormDBMonitor(monitor)
|
||||
|
||||
gormMonitor.monitor = nil
|
||||
|
||||
ctx := context.Background()
|
||||
stmt := &gorm.Statement{
|
||||
Context: ctx,
|
||||
Table: "users",
|
||||
}
|
||||
|
||||
mockDB := &gorm.DB{
|
||||
Statement: stmt,
|
||||
}
|
||||
|
||||
gormMonitor.beforeCreate(mockDB)
|
||||
gormMonitor.beforeQuery(mockDB)
|
||||
gormMonitor.beforeUpdate(mockDB)
|
||||
gormMonitor.beforeDelete(mockDB)
|
||||
gormMonitor.beforeRow(mockDB)
|
||||
gormMonitor.beforeRaw(mockDB)
|
||||
|
||||
gormMonitor.afterCreate(mockDB)
|
||||
gormMonitor.afterQuery(mockDB)
|
||||
gormMonitor.afterUpdate(mockDB)
|
||||
gormMonitor.afterDelete(mockDB)
|
||||
gormMonitor.afterRow(mockDB)
|
||||
gormMonitor.afterRaw(mockDB)
|
||||
}
|
||||
|
||||
func TestGormDBMonitor_WithRealDatabase(t *testing.T) {
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
gormMonitor := NewGormDBMonitor(monitor)
|
||||
|
||||
dbName := "file:memdb_" + t.Name() + "?mode=memory&cache=private"
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := db.AutoMigrate(&User{}); err != nil {
|
||||
t.Fatalf("Failed to migrate database: %v", err)
|
||||
}
|
||||
|
||||
err = gormMonitor.Initialize(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize plugin: %v", err)
|
||||
}
|
||||
|
||||
user := &User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
var foundUser User
|
||||
if err := db.First(&foundUser, user.ID).Error; err != nil {
|
||||
t.Fatalf("Failed to find user: %v", err)
|
||||
}
|
||||
|
||||
foundUser.Username = "updateduser"
|
||||
if err := db.Save(&foundUser).Error; err != nil {
|
||||
t.Fatalf("Failed to update user: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Delete(&foundUser).Error; err != nil {
|
||||
t.Fatalf("Failed to delete user: %v", err)
|
||||
}
|
||||
|
||||
stats := monitor.GetStats()
|
||||
if stats.TotalQueries == 0 {
|
||||
t.Error("Expected monitor to have recorded some queries")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type SecureLogger struct {
|
||||
writer logger.Writer
|
||||
config logger.Config
|
||||
sensitiveFields []string
|
||||
sensitivePattern *regexp.Regexp
|
||||
productionMode bool
|
||||
}
|
||||
|
||||
func NewSecureLogger(writer logger.Writer, config logger.Config, productionMode bool) *SecureLogger {
|
||||
sensitiveFields := []string{
|
||||
"password", "token", "secret", "key", "hash", "salt",
|
||||
"email_verification_token", "password_reset_token",
|
||||
"token_hash", "jwt_secret", "api_key", "access_token",
|
||||
"refresh_token", "session_id", "cookie", "auth",
|
||||
}
|
||||
|
||||
sensitivePattern := regexp.MustCompile(`(?i)(password|token|secret|key|hash|salt|email_verification_token|password_reset_token|token_hash|jwt_secret|api_key|access_token|refresh_token|session_id|cookie|auth)`)
|
||||
|
||||
return &SecureLogger{
|
||||
writer: writer,
|
||||
config: config,
|
||||
sensitiveFields: sensitiveFields,
|
||||
sensitivePattern: sensitivePattern,
|
||||
productionMode: productionMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *SecureLogger) LogMode(level logger.LogLevel) logger.Interface {
|
||||
newLogger := *l
|
||||
newLogger.config.LogLevel = level
|
||||
return &newLogger
|
||||
}
|
||||
|
||||
func (l *SecureLogger) Info(ctx context.Context, msg string, data ...any) {
|
||||
if l.config.LogLevel >= logger.Info {
|
||||
l.log(ctx, "info", msg, data...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *SecureLogger) Warn(ctx context.Context, msg string, data ...any) {
|
||||
if l.config.LogLevel >= logger.Warn {
|
||||
l.log(ctx, "warn", msg, data...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *SecureLogger) Error(ctx context.Context, msg string, data ...any) {
|
||||
if l.config.LogLevel >= logger.Error {
|
||||
l.log(ctx, "error", msg, data...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *SecureLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
if l.config.LogLevel <= logger.Silent {
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(begin)
|
||||
switch {
|
||||
case err != nil && l.config.LogLevel >= logger.Error && (!l.config.IgnoreRecordNotFoundError || !IsRecordNotFoundError(err)):
|
||||
sql, rows := fc()
|
||||
l.log(ctx, "error", fmt.Sprintf("[%.3fms] [rows:%v] %s", float64(elapsed.Nanoseconds())/1e6, rows, sql))
|
||||
case elapsed > l.config.SlowThreshold && l.config.SlowThreshold != 0 && l.config.LogLevel >= logger.Warn:
|
||||
sql, rows := fc()
|
||||
l.log(ctx, "warn", fmt.Sprintf("[%.3fms] [rows:%v] %s", float64(elapsed.Nanoseconds())/1e6, rows, sql))
|
||||
case l.config.LogLevel == logger.Info:
|
||||
sql, rows := fc()
|
||||
l.log(ctx, "info", fmt.Sprintf("[%.3fms] [rows:%v] %s", float64(elapsed.Nanoseconds())/1e6, rows, sql))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *SecureLogger) log(_ context.Context, level, msg string, data ...any) {
|
||||
if l.productionMode {
|
||||
msg = l.maskSensitiveData(msg)
|
||||
|
||||
maskedData := make([]any, len(data))
|
||||
for i, d := range data {
|
||||
maskedData[i] = l.maskSensitiveData(fmt.Sprintf("%v", d))
|
||||
}
|
||||
data = maskedData
|
||||
}
|
||||
|
||||
formattedMsg := fmt.Sprintf(msg, data...)
|
||||
|
||||
l.writer.Printf("[%s] %s", strings.ToUpper(level), formattedMsg)
|
||||
}
|
||||
|
||||
func (l *SecureLogger) maskSensitiveData(data string) string {
|
||||
if l.productionMode {
|
||||
data = regexp.MustCompile(`\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b`).ReplaceAllString(data, "[EMAIL_MASKED]")
|
||||
|
||||
data = regexp.MustCompile(`\b[A-Za-z0-9]{20,}\b`).ReplaceAllStringFunc(data, func(match string) string {
|
||||
if l.sensitivePattern.MatchString(match) {
|
||||
return "[TOKEN_MASKED]"
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
data = l.maskSQLValues(data)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (l *SecureLogger) maskSQLValues(sql string) string {
|
||||
paramPattern := regexp.MustCompile(`'([^']*)'`)
|
||||
|
||||
return paramPattern.ReplaceAllStringFunc(sql, func(match string) string {
|
||||
value := strings.Trim(match, "'")
|
||||
|
||||
if l.isSensitiveValue(value) {
|
||||
return "'[MASKED]'"
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
func (l *SecureLogger) isSensitiveValue(value string) bool {
|
||||
if regexp.MustCompile(`\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b`).MatchString(value) {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(value) > 20 && regexp.MustCompile(`^[A-Za-z0-9+/]{20,}={0,2}$`).MatchString(value) {
|
||||
return true
|
||||
}
|
||||
|
||||
if regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`).MatchString(value) {
|
||||
return true
|
||||
}
|
||||
|
||||
if regexp.MustCompile(`^[A-Za-z0-9+/]+={0,2}$`).MatchString(value) && len(value) > 10 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func IsRecordNotFoundError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "record not found") ||
|
||||
strings.Contains(strings.ToLower(err.Error()), "not found")
|
||||
}
|
||||
|
||||
func CreateSecureLogger(productionMode bool) logger.Interface {
|
||||
config := logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
}
|
||||
|
||||
if productionMode {
|
||||
config.LogLevel = logger.Error
|
||||
config.SlowThreshold = 2 * time.Second
|
||||
}
|
||||
|
||||
writer := log.New(os.Stdout, "\r\n", log.LstdFlags)
|
||||
return NewSecureLogger(writer, config, productionMode)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestSecureLogger_MaskSensitiveData(t *testing.T) {
|
||||
writer := log.New(os.Stdout, "\r\n", log.LstdFlags)
|
||||
config := logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
production bool
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "development_mode_no_masking",
|
||||
production: false,
|
||||
input: "SELECT * FROM users WHERE email = 'user@example.com'",
|
||||
expected: "SELECT * FROM users WHERE email = 'user@example.com'",
|
||||
},
|
||||
{
|
||||
name: "production_mode_mask_email",
|
||||
production: true,
|
||||
input: "SELECT * FROM users WHERE email = 'user@example.com'",
|
||||
expected: "SELECT * FROM users WHERE email = '[EMAIL_MASKED]'",
|
||||
},
|
||||
{
|
||||
name: "production_mode_mask_token",
|
||||
production: true,
|
||||
input: "SELECT * FROM users WHERE password_reset_token = 'abc123def456ghi789'",
|
||||
expected: "SELECT * FROM users WHERE password_reset_token = '[TOKEN_MASKED]'",
|
||||
},
|
||||
{
|
||||
name: "production_mode_mask_uuid",
|
||||
production: true,
|
||||
input: "SELECT * FROM users WHERE id = '550e8400-e29b-41d4-a716-446655440000'",
|
||||
expected: "SELECT * FROM users WHERE id = '[TOKEN_MASKED]'",
|
||||
},
|
||||
{
|
||||
name: "production_mode_no_masking_short_values",
|
||||
production: true,
|
||||
input: "SELECT * FROM users WHERE id = 123",
|
||||
expected: "SELECT * FROM users WHERE id = 123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
secureLogger := NewSecureLogger(writer, config, tt.production)
|
||||
result := secureLogger.maskSensitiveData(tt.input)
|
||||
|
||||
if tt.production {
|
||||
if strings.Contains(result, "user@example.com") {
|
||||
t.Errorf("Email should be masked in production mode")
|
||||
}
|
||||
if strings.Contains(result, "abc123def456ghi789") {
|
||||
t.Errorf("Token should be masked in production mode")
|
||||
}
|
||||
} else {
|
||||
if result != tt.input {
|
||||
t.Errorf("Expected %q, got %q", tt.input, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureLogger_IsSensitiveValue(t *testing.T) {
|
||||
writer := log.New(os.Stdout, "\r\n", log.LstdFlags)
|
||||
config := logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
}
|
||||
|
||||
secureLogger := NewSecureLogger(writer, config, true)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "email_address",
|
||||
value: "user@example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "long_token",
|
||||
value: "abc123def456ghi789jkl012mno345pqr678stu901vwx234yz",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "uuid",
|
||||
value: "550e8400-e29b-41d4-a716-446655440000",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "short_value",
|
||||
value: "123",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "normal_text",
|
||||
value: "golang programming",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "base64_like",
|
||||
value: "SGVsbG8gV29ybGQ=",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := secureLogger.isSensitiveValue(tt.value)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v for value %q, got %v", tt.expected, tt.value, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureLogger_LogLevels(t *testing.T) {
|
||||
writer := log.New(os.Stdout, "\r\n", log.LstdFlags)
|
||||
config := logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
}
|
||||
|
||||
secureLogger := NewSecureLogger(writer, config, false)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
secureLogger.Info(ctx, "Test info message")
|
||||
secureLogger.Warn(ctx, "Test warn message")
|
||||
secureLogger.Error(ctx, "Test error message")
|
||||
}
|
||||
|
||||
func TestCreateSecureLogger(t *testing.T) {
|
||||
prodLogger := CreateSecureLogger(true)
|
||||
if prodLogger == nil {
|
||||
t.Error("Expected non-nil logger for production mode")
|
||||
}
|
||||
|
||||
devLogger := CreateSecureLogger(false)
|
||||
if devLogger == nil {
|
||||
t.Error("Expected non-nil logger for development mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureLogger_LogMode(t *testing.T) {
|
||||
writer := log.New(os.Stdout, "\r\n", log.LstdFlags)
|
||||
config := logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
}
|
||||
|
||||
secureLogger := NewSecureLogger(writer, config, false)
|
||||
|
||||
newLogger := secureLogger.LogMode(logger.Error)
|
||||
if newLogger == nil {
|
||||
t.Error("Expected non-nil logger from LogMode")
|
||||
}
|
||||
|
||||
if secureLogger.config.LogLevel != logger.Info {
|
||||
t.Error("Original logger should be unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureLogger_Trace(t *testing.T) {
|
||||
writer := log.New(os.Stdout, "\r\n", log.LstdFlags)
|
||||
config := logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
}
|
||||
|
||||
secureLogger := NewSecureLogger(writer, config, false)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("silent_level", func(t *testing.T) {
|
||||
silentLogger := secureLogger.LogMode(logger.Silent)
|
||||
silentLogger.Trace(ctx, time.Now(), func() (string, int64) {
|
||||
return "SELECT * FROM users", 1
|
||||
}, nil)
|
||||
})
|
||||
|
||||
t.Run("error_level_with_error", func(t *testing.T) {
|
||||
errorLogger := secureLogger.LogMode(logger.Error)
|
||||
errorLogger.Trace(ctx, time.Now(), func() (string, int64) {
|
||||
return "SELECT * FROM users", 1
|
||||
}, errors.New("test error"))
|
||||
})
|
||||
|
||||
t.Run("warn_level_slow_query", func(t *testing.T) {
|
||||
warnLogger := secureLogger.LogMode(logger.Warn)
|
||||
|
||||
startTime := time.Now().Add(-2 * time.Second)
|
||||
warnLogger.Trace(ctx, startTime, func() (string, int64) {
|
||||
return "SELECT * FROM users", 1
|
||||
}, nil)
|
||||
})
|
||||
|
||||
t.Run("info_level", func(t *testing.T) {
|
||||
infoLogger := secureLogger.LogMode(logger.Info)
|
||||
infoLogger.Trace(ctx, time.Now(), func() (string, int64) {
|
||||
return "SELECT * FROM users", 1
|
||||
}, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecureLogger_MaskSQLValues(t *testing.T) {
|
||||
writer := log.New(os.Stdout, "\r\n", log.LstdFlags)
|
||||
config := logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
}
|
||||
|
||||
secureLogger := NewSecureLogger(writer, config, true)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sql string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "email_in_sql",
|
||||
sql: "SELECT * FROM users WHERE email = 'user@example.com'",
|
||||
expected: "SELECT * FROM users WHERE email = '[MASKED]'",
|
||||
},
|
||||
{
|
||||
name: "token_in_sql",
|
||||
sql: "SELECT * FROM users WHERE token = 'abc123def456ghi789'",
|
||||
expected: "SELECT * FROM users WHERE token = '[MASKED]'",
|
||||
},
|
||||
{
|
||||
name: "uuid_in_sql",
|
||||
sql: "SELECT * FROM users WHERE id = '550e8400-e29b-41d4-a716-446655440000'",
|
||||
expected: "SELECT * FROM users WHERE id = '[MASKED]'",
|
||||
},
|
||||
{
|
||||
name: "normal_value",
|
||||
sql: "SELECT * FROM users WHERE id = 123",
|
||||
expected: "SELECT * FROM users WHERE id = 123",
|
||||
},
|
||||
{
|
||||
name: "multiple_values",
|
||||
sql: "SELECT * FROM users WHERE email = 'user@example.com' AND id = 123",
|
||||
expected: "SELECT * FROM users WHERE email = '[MASKED]' AND id = 123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := secureLogger.maskSQLValues(tt.sql)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureLogger_IsRecordNotFoundError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "record_not_found",
|
||||
err: errors.New("record not found"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "not_found",
|
||||
err: errors.New("not found"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "RECORD NOT FOUND",
|
||||
err: errors.New("RECORD NOT FOUND"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "NOT FOUND",
|
||||
err: errors.New("NOT FOUND"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "other_error",
|
||||
err: errors.New("connection failed"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nil_error",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsRecordNotFoundError(tt.err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v for error '%v', got %v", tt.expected, tt.err, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureLogger_ProductionMode(t *testing.T) {
|
||||
writer := log.New(os.Stdout, "\r\n", log.LstdFlags)
|
||||
config := logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Error,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
}
|
||||
|
||||
secureLogger := NewSecureLogger(writer, config, true)
|
||||
ctx := context.Background()
|
||||
|
||||
secureLogger.Info(ctx, "User login: %s", "user@example.com")
|
||||
secureLogger.Warn(ctx, "Token validation: %s", "abc123def456ghi789")
|
||||
secureLogger.Error(ctx, "Database error: %s", "connection failed")
|
||||
}
|
||||
|
||||
func TestSecureLogger_DevelopmentMode(t *testing.T) {
|
||||
writer := log.New(os.Stdout, "\r\n", log.LstdFlags)
|
||||
config := logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: false,
|
||||
}
|
||||
|
||||
secureLogger := NewSecureLogger(writer, config, false)
|
||||
ctx := context.Background()
|
||||
|
||||
secureLogger.Info(ctx, "User login: %s", "user@example.com")
|
||||
secureLogger.Warn(ctx, "Token validation: %s", "abc123def456ghi789")
|
||||
secureLogger.Error(ctx, "Database error: %s", "connection failed")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
)
|
||||
|
||||
type PostDTO struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content,omitempty"`
|
||||
AuthorID *uint `json:"author_id,omitempty"`
|
||||
AuthorName string `json:"author_name,omitempty"`
|
||||
Author *UserDTO `json:"author,omitempty"`
|
||||
UpVotes int `json:"up_votes"`
|
||||
DownVotes int `json:"down_votes"`
|
||||
Score int `json:"score"`
|
||||
CurrentVote string `json:"current_vote,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PostListDTO struct {
|
||||
Posts []PostDTO `json:"posts"`
|
||||
Count int `json:"count"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
func ToPostDTO(post *database.Post) PostDTO {
|
||||
if post == nil {
|
||||
return PostDTO{}
|
||||
}
|
||||
|
||||
dto := PostDTO{
|
||||
ID: post.ID,
|
||||
Title: post.Title,
|
||||
URL: post.URL,
|
||||
Content: post.Content,
|
||||
AuthorID: post.AuthorID,
|
||||
AuthorName: post.AuthorName,
|
||||
UpVotes: post.UpVotes,
|
||||
DownVotes: post.DownVotes,
|
||||
Score: post.Score,
|
||||
CreatedAt: post.CreatedAt,
|
||||
UpdatedAt: post.UpdatedAt,
|
||||
}
|
||||
|
||||
if post.CurrentVote != "" {
|
||||
dto.CurrentVote = string(post.CurrentVote)
|
||||
}
|
||||
|
||||
if post.Author.ID != 0 {
|
||||
authorDTO := ToUserDTO(&post.Author)
|
||||
dto.Author = &authorDTO
|
||||
}
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
func ToPostDTOs(posts []database.Post) []PostDTO {
|
||||
dtos := make([]PostDTO, len(posts))
|
||||
for i := range posts {
|
||||
dtos[i] = ToPostDTO(&posts[i])
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
)
|
||||
|
||||
func TestToPostDTO(t *testing.T) {
|
||||
t.Run("nil post", func(t *testing.T) {
|
||||
dto := ToPostDTO(nil)
|
||||
if dto.ID != 0 {
|
||||
t.Errorf("Expected zero value for nil post, got ID %d", dto.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid post without author", func(t *testing.T) {
|
||||
post := &database.Post{
|
||||
ID: 1,
|
||||
Title: "Test Post",
|
||||
URL: "https://example.com",
|
||||
Content: "Test content",
|
||||
AuthorID: nil,
|
||||
AuthorName: "",
|
||||
UpVotes: 5,
|
||||
DownVotes: 2,
|
||||
Score: 3,
|
||||
CurrentVote: database.VoteUp,
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
dto := ToPostDTO(post)
|
||||
|
||||
if dto.ID != post.ID {
|
||||
t.Errorf("Expected ID %d, got %d", post.ID, dto.ID)
|
||||
}
|
||||
if dto.Title != post.Title {
|
||||
t.Errorf("Expected Title %q, got %q", post.Title, dto.Title)
|
||||
}
|
||||
if dto.URL != post.URL {
|
||||
t.Errorf("Expected URL %q, got %q", post.URL, dto.URL)
|
||||
}
|
||||
if dto.Content != post.Content {
|
||||
t.Errorf("Expected Content %q, got %q", post.Content, dto.Content)
|
||||
}
|
||||
if dto.UpVotes != post.UpVotes {
|
||||
t.Errorf("Expected UpVotes %d, got %d", post.UpVotes, dto.UpVotes)
|
||||
}
|
||||
if dto.DownVotes != post.DownVotes {
|
||||
t.Errorf("Expected DownVotes %d, got %d", post.DownVotes, dto.DownVotes)
|
||||
}
|
||||
if dto.Score != post.Score {
|
||||
t.Errorf("Expected Score %d, got %d", post.Score, dto.Score)
|
||||
}
|
||||
if dto.CurrentVote != string(post.CurrentVote) {
|
||||
t.Errorf("Expected CurrentVote %q, got %q", post.CurrentVote, dto.CurrentVote)
|
||||
}
|
||||
if !dto.CreatedAt.Equal(post.CreatedAt) {
|
||||
t.Errorf("Expected CreatedAt %v, got %v", post.CreatedAt, dto.CreatedAt)
|
||||
}
|
||||
if !dto.UpdatedAt.Equal(post.UpdatedAt) {
|
||||
t.Errorf("Expected UpdatedAt %v, got %v", post.UpdatedAt, dto.UpdatedAt)
|
||||
}
|
||||
if dto.Author != nil {
|
||||
t.Error("Expected Author to be nil when post.Author.ID is 0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post with author", func(t *testing.T) {
|
||||
authorID := uint(42)
|
||||
post := &database.Post{
|
||||
ID: 1,
|
||||
Title: "Test Post",
|
||||
URL: "https://example.com",
|
||||
AuthorID: &authorID,
|
||||
AuthorName: "Test Author",
|
||||
Author: database.User{
|
||||
ID: authorID,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
},
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
dto := ToPostDTO(post)
|
||||
|
||||
if dto.AuthorID == nil || *dto.AuthorID != authorID {
|
||||
t.Errorf("Expected AuthorID %d, got %v", authorID, dto.AuthorID)
|
||||
}
|
||||
if dto.AuthorName != post.AuthorName {
|
||||
t.Errorf("Expected AuthorName %q, got %q", post.AuthorName, dto.AuthorName)
|
||||
}
|
||||
if dto.Author == nil {
|
||||
t.Fatal("Expected Author to be set")
|
||||
}
|
||||
if dto.Author.ID != authorID {
|
||||
t.Errorf("Expected Author.ID %d, got %d", authorID, dto.Author.ID)
|
||||
}
|
||||
if dto.Author.Username != post.Author.Username {
|
||||
t.Errorf("Expected Author.Username %q, got %q", post.Author.Username, dto.Author.Username)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post with VoteNone", func(t *testing.T) {
|
||||
post := &database.Post{
|
||||
ID: 1,
|
||||
Title: "Test Post",
|
||||
CurrentVote: database.VoteNone,
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
dto := ToPostDTO(post)
|
||||
|
||||
if dto.CurrentVote != "none" {
|
||||
t.Errorf("Expected CurrentVote %q, got %q", "none", dto.CurrentVote)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post without CurrentVote set", func(t *testing.T) {
|
||||
post := &database.Post{
|
||||
ID: 1,
|
||||
Title: "Test Post",
|
||||
CurrentVote: "",
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
dto := ToPostDTO(post)
|
||||
|
||||
if dto.CurrentVote != "" {
|
||||
t.Errorf("Expected empty CurrentVote, got %q", dto.CurrentVote)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToPostDTOs(t *testing.T) {
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
posts := []database.Post{}
|
||||
dtos := ToPostDTOs(posts)
|
||||
if len(dtos) != 0 {
|
||||
t.Errorf("Expected empty slice, got %d items", len(dtos))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple posts", func(t *testing.T) {
|
||||
posts := []database.Post{
|
||||
{
|
||||
ID: 1,
|
||||
Title: "Post 1",
|
||||
URL: "https://example.com/1",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Title: "Post 2",
|
||||
URL: "https://example.com/2",
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Title: "Post 3",
|
||||
URL: "https://example.com/3",
|
||||
},
|
||||
}
|
||||
|
||||
dtos := ToPostDTOs(posts)
|
||||
|
||||
if len(dtos) != len(posts) {
|
||||
t.Fatalf("Expected %d DTOs, got %d", len(posts), len(dtos))
|
||||
}
|
||||
|
||||
for i := range posts {
|
||||
if dtos[i].ID != posts[i].ID {
|
||||
t.Errorf("Post %d: Expected ID %d, got %d", i, posts[i].ID, dtos[i].ID)
|
||||
}
|
||||
if dtos[i].Title != posts[i].Title {
|
||||
t.Errorf("Post %d: Expected Title %q, got %q", i, posts[i].Title, dtos[i].Title)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
)
|
||||
|
||||
type UserDTO struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UserListDTO struct {
|
||||
Users []UserDTO `json:"users"`
|
||||
Count int `json:"count"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
func ToUserDTO(user *database.User) UserDTO {
|
||||
if user == nil {
|
||||
return UserDTO{}
|
||||
}
|
||||
|
||||
return UserDTO{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
EmailVerifiedAt: user.EmailVerifiedAt,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ToUserDTOs(users []database.User) []UserDTO {
|
||||
dtos := make([]UserDTO, len(users))
|
||||
for i := range users {
|
||||
dtos[i] = ToUserDTO(&users[i])
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
type SanitizedUserDTO struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func ToSanitizedUserDTO(user *database.User) SanitizedUserDTO {
|
||||
if user == nil {
|
||||
return SanitizedUserDTO{}
|
||||
}
|
||||
|
||||
return SanitizedUserDTO{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ToSanitizedUserDTOs(users []database.User) []SanitizedUserDTO {
|
||||
dtos := make([]SanitizedUserDTO, len(users))
|
||||
for i := range users {
|
||||
dtos[i] = ToSanitizedUserDTO(&users[i])
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
)
|
||||
|
||||
func TestToUserDTO(t *testing.T) {
|
||||
t.Run("nil user", func(t *testing.T) {
|
||||
dto := ToUserDTO(nil)
|
||||
if dto.ID != 0 {
|
||||
t.Errorf("Expected zero value for nil user, got ID %d", dto.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid user", func(t *testing.T) {
|
||||
verifiedAt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
user := &database.User{
|
||||
ID: 42,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
EmailVerified: true,
|
||||
EmailVerifiedAt: &verifiedAt,
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
dto := ToUserDTO(user)
|
||||
|
||||
if dto.ID != user.ID {
|
||||
t.Errorf("Expected ID %d, got %d", user.ID, dto.ID)
|
||||
}
|
||||
if dto.Username != user.Username {
|
||||
t.Errorf("Expected Username %q, got %q", user.Username, dto.Username)
|
||||
}
|
||||
if dto.Email != user.Email {
|
||||
t.Errorf("Expected Email %q, got %q", user.Email, dto.Email)
|
||||
}
|
||||
if dto.EmailVerified != user.EmailVerified {
|
||||
t.Errorf("Expected EmailVerified %v, got %v", user.EmailVerified, dto.EmailVerified)
|
||||
}
|
||||
if dto.EmailVerifiedAt == nil || !dto.EmailVerifiedAt.Equal(*user.EmailVerifiedAt) {
|
||||
t.Errorf("Expected EmailVerifiedAt %v, got %v", user.EmailVerifiedAt, dto.EmailVerifiedAt)
|
||||
}
|
||||
if !dto.CreatedAt.Equal(user.CreatedAt) {
|
||||
t.Errorf("Expected CreatedAt %v, got %v", user.CreatedAt, dto.CreatedAt)
|
||||
}
|
||||
if !dto.UpdatedAt.Equal(user.UpdatedAt) {
|
||||
t.Errorf("Expected UpdatedAt %v, got %v", user.UpdatedAt, dto.UpdatedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user without email verified at", func(t *testing.T) {
|
||||
user := &database.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
EmailVerified: false,
|
||||
EmailVerifiedAt: nil,
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
dto := ToUserDTO(user)
|
||||
|
||||
if dto.EmailVerifiedAt != nil {
|
||||
t.Error("Expected EmailVerifiedAt to be nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToUserDTOs(t *testing.T) {
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
users := []database.User{}
|
||||
dtos := ToUserDTOs(users)
|
||||
if len(dtos) != 0 {
|
||||
t.Errorf("Expected empty slice, got %d items", len(dtos))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple users", func(t *testing.T) {
|
||||
users := []database.User{
|
||||
{
|
||||
ID: 1,
|
||||
Username: "user1",
|
||||
Email: "user1@example.com",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Username: "user2",
|
||||
Email: "user2@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
dtos := ToUserDTOs(users)
|
||||
|
||||
if len(dtos) != len(users) {
|
||||
t.Fatalf("Expected %d DTOs, got %d", len(users), len(dtos))
|
||||
}
|
||||
|
||||
for i := range users {
|
||||
if dtos[i].ID != users[i].ID {
|
||||
t.Errorf("User %d: Expected ID %d, got %d", i, users[i].ID, dtos[i].ID)
|
||||
}
|
||||
if dtos[i].Username != users[i].Username {
|
||||
t.Errorf("User %d: Expected Username %q, got %q", i, users[i].Username, dtos[i].Username)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToSanitizedUserDTO(t *testing.T) {
|
||||
t.Run("nil user", func(t *testing.T) {
|
||||
dto := ToSanitizedUserDTO(nil)
|
||||
if dto.ID != 0 {
|
||||
t.Errorf("Expected zero value for nil user, got ID %d", dto.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid user", func(t *testing.T) {
|
||||
user := &database.User{
|
||||
ID: 42,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
EmailVerified: true,
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
dto := ToSanitizedUserDTO(user)
|
||||
|
||||
if dto.ID != user.ID {
|
||||
t.Errorf("Expected ID %d, got %d", user.ID, dto.ID)
|
||||
}
|
||||
if dto.Username != user.Username {
|
||||
t.Errorf("Expected Username %q, got %q", user.Username, dto.Username)
|
||||
}
|
||||
if !dto.CreatedAt.Equal(user.CreatedAt) {
|
||||
t.Errorf("Expected CreatedAt %v, got %v", user.CreatedAt, dto.CreatedAt)
|
||||
}
|
||||
if !dto.UpdatedAt.Equal(user.UpdatedAt) {
|
||||
t.Errorf("Expected UpdatedAt %v, got %v", user.UpdatedAt, dto.UpdatedAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToSanitizedUserDTOs(t *testing.T) {
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
users := []database.User{}
|
||||
dtos := ToSanitizedUserDTOs(users)
|
||||
if len(dtos) != 0 {
|
||||
t.Errorf("Expected empty slice, got %d items", len(dtos))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple users", func(t *testing.T) {
|
||||
users := []database.User{
|
||||
{
|
||||
ID: 1,
|
||||
Username: "user1",
|
||||
Email: "user1@example.com",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Username: "user2",
|
||||
Email: "user2@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
dtos := ToSanitizedUserDTOs(users)
|
||||
|
||||
if len(dtos) != len(users) {
|
||||
t.Fatalf("Expected %d DTOs, got %d", len(users), len(dtos))
|
||||
}
|
||||
|
||||
for i := range users {
|
||||
if dtos[i].ID != users[i].ID {
|
||||
t.Errorf("User %d: Expected ID %d, got %d", i, users[i].ID, dtos[i].ID)
|
||||
}
|
||||
if dtos[i].Username != users[i].Username {
|
||||
t.Errorf("User %d: Expected Username %q, got %q", i, users[i].Username, dtos[i].Username)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
)
|
||||
|
||||
type VoteDTO struct {
|
||||
ID uint `json:"id"`
|
||||
UserID *uint `json:"user_id,omitempty"`
|
||||
PostID uint `json:"post_id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func ToVoteDTO(vote *database.Vote) VoteDTO {
|
||||
if vote == nil {
|
||||
return VoteDTO{}
|
||||
}
|
||||
|
||||
return VoteDTO{
|
||||
ID: vote.ID,
|
||||
UserID: vote.UserID,
|
||||
PostID: vote.PostID,
|
||||
Type: string(vote.Type),
|
||||
CreatedAt: vote.CreatedAt,
|
||||
UpdatedAt: vote.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ToVoteDTOs(votes []database.Vote) []VoteDTO {
|
||||
dtos := make([]VoteDTO, len(votes))
|
||||
for i := range votes {
|
||||
dtos[i] = ToVoteDTO(&votes[i])
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
)
|
||||
|
||||
func TestToVoteDTO(t *testing.T) {
|
||||
t.Run("nil vote", func(t *testing.T) {
|
||||
dto := ToVoteDTO(nil)
|
||||
if dto.ID != 0 {
|
||||
t.Errorf("Expected zero value for nil vote, got ID %d", dto.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vote with user ID", func(t *testing.T) {
|
||||
userID := uint(42)
|
||||
vote := &database.Vote{
|
||||
ID: 1,
|
||||
UserID: &userID,
|
||||
PostID: 10,
|
||||
Type: database.VoteUp,
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
dto := ToVoteDTO(vote)
|
||||
|
||||
if dto.ID != vote.ID {
|
||||
t.Errorf("Expected ID %d, got %d", vote.ID, dto.ID)
|
||||
}
|
||||
if dto.UserID == nil || *dto.UserID != userID {
|
||||
t.Errorf("Expected UserID %d, got %v", userID, dto.UserID)
|
||||
}
|
||||
if dto.PostID != vote.PostID {
|
||||
t.Errorf("Expected PostID %d, got %d", vote.PostID, dto.PostID)
|
||||
}
|
||||
if dto.Type != string(vote.Type) {
|
||||
t.Errorf("Expected Type %q, got %q", vote.Type, dto.Type)
|
||||
}
|
||||
if !dto.CreatedAt.Equal(vote.CreatedAt) {
|
||||
t.Errorf("Expected CreatedAt %v, got %v", vote.CreatedAt, dto.CreatedAt)
|
||||
}
|
||||
if !dto.UpdatedAt.Equal(vote.UpdatedAt) {
|
||||
t.Errorf("Expected UpdatedAt %v, got %v", vote.UpdatedAt, dto.UpdatedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vote without user ID", func(t *testing.T) {
|
||||
vote := &database.Vote{
|
||||
ID: 2,
|
||||
UserID: nil,
|
||||
PostID: 20,
|
||||
Type: database.VoteDown,
|
||||
CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
dto := ToVoteDTO(vote)
|
||||
|
||||
if dto.UserID != nil {
|
||||
t.Errorf("Expected UserID to be nil, got %v", dto.UserID)
|
||||
}
|
||||
if dto.Type != string(database.VoteDown) {
|
||||
t.Errorf("Expected Type %q, got %q", database.VoteDown, dto.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all vote types", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
voteType database.VoteType
|
||||
expected string
|
||||
}{
|
||||
{"VoteUp", database.VoteUp, "up"},
|
||||
{"VoteDown", database.VoteDown, "down"},
|
||||
{"VoteNone", database.VoteNone, "none"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
vote := &database.Vote{
|
||||
ID: 1,
|
||||
Type: tt.voteType,
|
||||
}
|
||||
|
||||
dto := ToVoteDTO(vote)
|
||||
|
||||
if dto.Type != tt.expected {
|
||||
t.Errorf("Expected Type %q, got %q", tt.expected, dto.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestToVoteDTOs(t *testing.T) {
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
votes := []database.Vote{}
|
||||
dtos := ToVoteDTOs(votes)
|
||||
if len(dtos) != 0 {
|
||||
t.Errorf("Expected empty slice, got %d items", len(dtos))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple votes", func(t *testing.T) {
|
||||
userID1 := uint(1)
|
||||
votes := []database.Vote{
|
||||
{
|
||||
ID: 1,
|
||||
UserID: &userID1,
|
||||
PostID: 10,
|
||||
Type: database.VoteUp,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
UserID: nil,
|
||||
PostID: 10,
|
||||
Type: database.VoteDown,
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
UserID: &userID1,
|
||||
PostID: 20,
|
||||
Type: database.VoteUp,
|
||||
},
|
||||
}
|
||||
|
||||
dtos := ToVoteDTOs(votes)
|
||||
|
||||
if len(dtos) != len(votes) {
|
||||
t.Fatalf("Expected %d DTOs, got %d", len(votes), len(dtos))
|
||||
}
|
||||
|
||||
for i := range votes {
|
||||
if dtos[i].ID != votes[i].ID {
|
||||
t.Errorf("Vote %d: Expected ID %d, got %d", i, votes[i].ID, dtos[i].ID)
|
||||
}
|
||||
if dtos[i].PostID != votes[i].PostID {
|
||||
t.Errorf("Vote %d: Expected PostID %d, got %d", i, votes[i].PostID, dtos[i].PostID)
|
||||
}
|
||||
if dtos[i].Type != string(votes[i].Type) {
|
||||
t.Errorf("Vote %d: Expected Type %q, got %q", i, votes[i].Type, dtos[i].Type)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_SwaggerDocumentation(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("swagger_json_is_valid", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skipf("Swagger JSON not available (status %d)", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var swaggerDoc map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&swaggerDoc); err != nil {
|
||||
t.Fatalf("Failed to decode Swagger JSON: %v", err)
|
||||
}
|
||||
|
||||
if swaggerDoc["swagger"] == nil && swaggerDoc["openapi"] == nil {
|
||||
t.Error("Swagger JSON missing swagger/openapi version")
|
||||
}
|
||||
|
||||
if swaggerDoc["info"] == nil {
|
||||
t.Error("Swagger JSON missing info section")
|
||||
}
|
||||
|
||||
if swaggerDoc["paths"] == nil {
|
||||
t.Error("Swagger JSON missing paths section")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("swagger_yaml_is_valid", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.yaml", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Logf("Swagger YAML endpoint returned status %d (may not be available)", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("api_endpoints_documented", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("Swagger JSON not available")
|
||||
return
|
||||
}
|
||||
|
||||
var swaggerDoc map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&swaggerDoc); err != nil {
|
||||
t.Fatalf("Failed to decode Swagger JSON: %v", err)
|
||||
}
|
||||
|
||||
paths, ok := swaggerDoc["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Paths section is not a map")
|
||||
return
|
||||
}
|
||||
|
||||
requiredPaths := []string{
|
||||
"/api",
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/me",
|
||||
"/api/posts",
|
||||
}
|
||||
|
||||
for _, requiredPath := range requiredPaths {
|
||||
if paths[requiredPath] == nil {
|
||||
t.Errorf("Required endpoint %s not documented", requiredPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("request_response_schemas_present", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("Swagger JSON not available")
|
||||
return
|
||||
}
|
||||
|
||||
var swaggerDoc map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&swaggerDoc); err != nil {
|
||||
t.Fatalf("Failed to decode Swagger JSON: %v", err)
|
||||
}
|
||||
|
||||
definitions, ok := swaggerDoc["definitions"].(map[string]interface{})
|
||||
if !ok {
|
||||
definitions, ok = swaggerDoc["components"].(map[string]interface{})
|
||||
if ok {
|
||||
definitions, _ = definitions["schemas"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
if definitions == nil {
|
||||
t.Log("No definitions/schemas section found (may use inline schemas)")
|
||||
return
|
||||
}
|
||||
|
||||
if len(definitions) == 0 {
|
||||
t.Error("Definitions/schemas section is empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("swagger_ui_accessible", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/index.html", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Logf("Swagger UI returned status %d (may not be available)", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_APIEndpointDocumentation(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("api_info_endpoint_documented", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("Swagger JSON not available")
|
||||
return
|
||||
}
|
||||
|
||||
var swaggerDoc map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&swaggerDoc); err != nil {
|
||||
t.Fatalf("Failed to decode Swagger JSON: %v", err)
|
||||
}
|
||||
|
||||
paths, ok := swaggerDoc["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
apiPath, ok := paths["/api"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("API endpoint not documented")
|
||||
return
|
||||
}
|
||||
|
||||
getMethod, ok := apiPath["get"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("API GET method not documented")
|
||||
return
|
||||
}
|
||||
|
||||
if getMethod["responses"] == nil {
|
||||
t.Error("API endpoint missing responses")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auth_endpoints_documented", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/swagger/doc.json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("Swagger JSON not available")
|
||||
return
|
||||
}
|
||||
|
||||
var swaggerDoc map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&swaggerDoc); err != nil {
|
||||
t.Fatalf("Failed to decode Swagger JSON: %v", err)
|
||||
}
|
||||
|
||||
paths, ok := swaggerDoc["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
authEndpoints := []string{
|
||||
"/api/auth/login",
|
||||
"/api/auth/register",
|
||||
}
|
||||
|
||||
for _, endpoint := range authEndpoints {
|
||||
endpointData, ok := paths[endpoint].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Auth endpoint %s not documented", endpoint)
|
||||
continue
|
||||
}
|
||||
|
||||
postMethod, ok := endpointData["post"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Auth endpoint %s missing POST method", endpoint)
|
||||
continue
|
||||
}
|
||||
|
||||
if postMethod["parameters"] == nil && postMethod["requestBody"] == nil {
|
||||
t.Logf("Auth endpoint %s may use inline request body", endpoint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"goyco/internal/database"
|
||||
)
|
||||
|
||||
func TestE2E_VoteCountConsistency(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("vote_count_consistency", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "voteuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "voteuser2", "Password123!")
|
||||
user3 := ctx.createUserWithCleanup(t, "voteuser3", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Vote Count Test", "https://example.com/votecount", "Content")
|
||||
|
||||
client1.VoteOnPost(t, post.ID, "up")
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post.ID, "up")
|
||||
client3 := ctx.loginUser(t, user3.Username, user3.Password)
|
||||
client3.VoteOnPost(t, post.ID, "down")
|
||||
|
||||
var dbPost database.Post
|
||||
if err := ctx.server.DB.First(&dbPost, post.ID).Error; err != nil {
|
||||
t.Fatalf("Failed to find post in database: %v", err)
|
||||
}
|
||||
|
||||
var voteCount int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ? AND type = ?", post.ID, database.VoteUp).Count(&voteCount)
|
||||
if voteCount != int64(dbPost.UpVotes) {
|
||||
t.Errorf("Expected upvote count %d to match database count %d", dbPost.UpVotes, voteCount)
|
||||
}
|
||||
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ? AND type = ?", post.ID, database.VoteDown).Count(&voteCount)
|
||||
if voteCount != int64(dbPost.DownVotes) {
|
||||
t.Errorf("Expected downvote count %d to match database count %d", dbPost.DownVotes, voteCount)
|
||||
}
|
||||
|
||||
postsResp := client1.GetPosts(t)
|
||||
apiPost := findPostInList(postsResp, post.ID)
|
||||
if apiPost == nil {
|
||||
t.Fatalf("Expected to find post in API response")
|
||||
}
|
||||
if apiPost.UpVotes != dbPost.UpVotes {
|
||||
t.Errorf("Expected API upvote count %d to match database %d", apiPost.UpVotes, dbPost.UpVotes)
|
||||
}
|
||||
if apiPost.DownVotes != dbPost.DownVotes {
|
||||
t.Errorf("Expected API downvote count %d to match database %d", apiPost.DownVotes, dbPost.DownVotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PostScoreCalculation(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("post_score_calculation", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "scoreuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "scoreuser2", "Password123!")
|
||||
user3 := ctx.createUserWithCleanup(t, "scoreuser3", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Score Test", "https://example.com/score", "Content")
|
||||
|
||||
client1.VoteOnPost(t, post.ID, "up")
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post.ID, "up")
|
||||
client3 := ctx.loginUser(t, user3.Username, user3.Password)
|
||||
client3.VoteOnPost(t, post.ID, "down")
|
||||
|
||||
var dbPost database.Post
|
||||
if err := ctx.server.DB.First(&dbPost, post.ID).Error; err != nil {
|
||||
t.Fatalf("Failed to find post in database: %v", err)
|
||||
}
|
||||
|
||||
expectedScore := dbPost.UpVotes - dbPost.DownVotes
|
||||
if dbPost.Score != expectedScore {
|
||||
t.Errorf("Expected score %d (upvotes %d - downvotes %d), got %d", expectedScore, dbPost.UpVotes, dbPost.DownVotes, dbPost.Score)
|
||||
}
|
||||
|
||||
postsResp := client1.GetPosts(t)
|
||||
apiPost := findPostInList(postsResp, post.ID)
|
||||
if apiPost == nil {
|
||||
t.Fatalf("Expected to find post in API response")
|
||||
}
|
||||
if apiPost.Score != expectedScore {
|
||||
t.Errorf("Expected API score %d to match calculated score %d", apiPost.Score, expectedScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PostDeletionCascades(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("post_deletion_cascades", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "cascadeuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "cascadeuser2", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Cascade Test", "https://example.com/cascade", "Content")
|
||||
|
||||
client1.VoteOnPost(t, post.ID, "up")
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post.ID, "down")
|
||||
|
||||
var voteCountBefore int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ?", post.ID).Count(&voteCountBefore)
|
||||
if voteCountBefore == 0 {
|
||||
t.Fatalf("Expected votes to exist before deletion")
|
||||
}
|
||||
|
||||
client1.DeletePost(t, post.ID)
|
||||
|
||||
var voteCountAfter int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ?", post.ID).Count(&voteCountAfter)
|
||||
if voteCountAfter != 0 {
|
||||
t.Errorf("Expected votes to be deleted after post deletion, found %d votes", voteCountAfter)
|
||||
}
|
||||
|
||||
var dbPost database.Post
|
||||
if err := ctx.server.DB.First(&dbPost, post.ID).Error; err == nil {
|
||||
t.Errorf("Expected post to be deleted from database")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_UserDeletionCascades(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("user_deletion_cascades", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "deleteuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "deleteuser2", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post1 := client1.CreatePost(t, "Post 1", "https://example.com/post1", "Content 1")
|
||||
post2 := client1.CreatePost(t, "Post 2", "https://example.com/post2", "Content 2")
|
||||
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post1.ID, "up")
|
||||
|
||||
var postCountBefore int64
|
||||
ctx.server.DB.Model(&database.Post{}).Where("author_id = ?", user1.ID).Count(&postCountBefore)
|
||||
if postCountBefore == 0 {
|
||||
t.Fatalf("Expected posts to exist before deletion")
|
||||
}
|
||||
|
||||
var voteCountBefore int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id IN (?)", []uint{post1.ID, post2.ID}).Count(&voteCountBefore)
|
||||
if voteCountBefore == 0 {
|
||||
t.Fatalf("Expected votes to exist before deletion")
|
||||
}
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
client1.RequestAccountDeletion(t)
|
||||
deletionToken := ctx.server.EmailSender.DeletionToken()
|
||||
if deletionToken == "" {
|
||||
t.Fatalf("Expected deletion token")
|
||||
}
|
||||
|
||||
client1.ConfirmAccountDeletion(t, deletionToken, false)
|
||||
|
||||
var postCountAfter int64
|
||||
ctx.server.DB.Model(&database.Post{}).Where("author_id = ?", user1.ID).Count(&postCountAfter)
|
||||
if postCountAfter != 0 {
|
||||
t.Errorf("Expected posts to be deleted after user deletion, found %d posts", postCountAfter)
|
||||
}
|
||||
|
||||
var voteCountAfter int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id IN (?)", []uint{post1.ID, post2.ID}).Count(&voteCountAfter)
|
||||
if voteCountAfter != 0 {
|
||||
t.Errorf("Expected votes to be deleted after post deletion, found %d votes", voteCountAfter)
|
||||
}
|
||||
|
||||
var dbUser database.User
|
||||
if err := ctx.server.DB.First(&dbUser, user1.ID).Error; err == nil {
|
||||
t.Errorf("Expected user to be deleted from database")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ReferentialIntegrity(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("referential_integrity", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "refuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "refuser2", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Ref Integrity Test", "https://example.com/ref", "Content")
|
||||
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post.ID, "up")
|
||||
|
||||
var voteCount int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ? AND user_id = ?", post.ID, user2.ID).Count(&voteCount)
|
||||
if voteCount != 1 {
|
||||
t.Errorf("Expected vote to exist with correct foreign keys")
|
||||
}
|
||||
|
||||
var postCount int64
|
||||
ctx.server.DB.Model(&database.Post{}).Where("author_id = ?", user1.ID).Count(&postCount)
|
||||
if postCount == 0 {
|
||||
t.Errorf("Expected post to exist with correct author foreign key")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_OrphanedRecordsPrevention(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("orphaned_records_prevention", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "orphanuser1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "orphanuser2", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Orphan Test", "https://example.com/orphan", "Content")
|
||||
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client2.VoteOnPost(t, post.ID, "up")
|
||||
|
||||
var voteCountBefore int64
|
||||
ctx.server.DB.Model(&database.Vote{}).Where("post_id = ?", post.ID).Count(&voteCountBefore)
|
||||
|
||||
client1.DeletePost(t, post.ID)
|
||||
|
||||
var orphanedVotes int64
|
||||
ctx.server.DB.Unscoped().Model(&database.Vote{}).Where("post_id = ?", post.ID).Count(&orphanedVotes)
|
||||
if orphanedVotes != 0 {
|
||||
t.Errorf("Expected no orphaned votes after post deletion, found %d", orphanedVotes)
|
||||
}
|
||||
|
||||
post2 := client1.CreatePost(t, "Orphan Test 2", "https://example.com/orphan2", "Content")
|
||||
client2.VoteOnPost(t, post2.ID, "up")
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
client1.RequestAccountDeletion(t)
|
||||
deletionToken := ctx.server.EmailSender.DeletionToken()
|
||||
if deletionToken == "" {
|
||||
t.Fatalf("Expected deletion token")
|
||||
}
|
||||
|
||||
client1.ConfirmAccountDeletion(t, deletionToken, false)
|
||||
|
||||
var orphanedPosts int64
|
||||
ctx.server.DB.Unscoped().Model(&database.Post{}).Where("author_id = ?", user1.ID).Count(&orphanedPosts)
|
||||
if orphanedPosts != 0 {
|
||||
t.Errorf("Expected no posts with author_id = %d after user deletion, found %d", user1.ID, orphanedPosts)
|
||||
}
|
||||
|
||||
var orphanedVotesAfter int64
|
||||
ctx.server.DB.Unscoped().Model(&database.Vote{}).Where("post_id = ?", post2.ID).Count(&orphanedVotesAfter)
|
||||
if orphanedVotesAfter != 0 {
|
||||
t.Errorf("Expected no orphaned votes after post deletion via user deletion, found %d", orphanedVotesAfter)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestE2E_DockerDeployment(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping Docker deployment tests in short mode")
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
workspaceRoot := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(workspaceRoot, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(workspaceRoot)
|
||||
if parent == workspaceRoot {
|
||||
t.Skip("Could not find workspace root")
|
||||
return
|
||||
}
|
||||
workspaceRoot = parent
|
||||
}
|
||||
|
||||
t.Run("dockerfile_exists", func(t *testing.T) {
|
||||
dockerfilePath := filepath.Join(workspaceRoot, "Dockerfile")
|
||||
if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) {
|
||||
t.Skipf("Dockerfile not found at %s", dockerfilePath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dockerfile_valid", func(t *testing.T) {
|
||||
dockerfilePath := filepath.Join(workspaceRoot, "Dockerfile")
|
||||
content, err := os.ReadFile(dockerfilePath)
|
||||
if err != nil {
|
||||
t.Skipf("Failed to read Dockerfile: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
required := []string{
|
||||
"FROM",
|
||||
"WORKDIR",
|
||||
"COPY",
|
||||
"RUN",
|
||||
"EXPOSE",
|
||||
}
|
||||
|
||||
for _, req := range required {
|
||||
if !strings.Contains(contentStr, req) {
|
||||
t.Errorf("Dockerfile missing required directive: %s", req)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("service_file_exists", func(t *testing.T) {
|
||||
servicePath := filepath.Join(workspaceRoot, "services/goyco.service")
|
||||
if _, err := os.Stat(servicePath); os.IsNotExist(err) {
|
||||
t.Skipf("Service file not found at %s", servicePath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("service_file_valid", func(t *testing.T) {
|
||||
servicePath := filepath.Join(workspaceRoot, "services/goyco.service")
|
||||
content, err := os.ReadFile(servicePath)
|
||||
if err != nil {
|
||||
t.Skipf("Failed to read service file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
required := []string{
|
||||
"[Unit]",
|
||||
"[Service]",
|
||||
"ExecStart",
|
||||
"Restart",
|
||||
}
|
||||
|
||||
for _, req := range required {
|
||||
if !strings.Contains(contentStr, req) {
|
||||
t.Errorf("Service file missing required section: %s", req)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static_files_exist", func(t *testing.T) {
|
||||
staticDir := filepath.Join(workspaceRoot, "internal/static")
|
||||
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
|
||||
t.Skipf("Static directory not found at %s", staticDir)
|
||||
return
|
||||
}
|
||||
|
||||
requiredFiles := []string{
|
||||
"robots.txt",
|
||||
}
|
||||
|
||||
for _, file := range requiredFiles {
|
||||
filePath := filepath.Join(staticDir, file)
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
t.Errorf("Required static file not found: %s", filePath)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("templates_exist", func(t *testing.T) {
|
||||
templatesDir := filepath.Join(workspaceRoot, "internal/templates")
|
||||
if _, err := os.Stat(templatesDir); os.IsNotExist(err) {
|
||||
t.Skipf("Templates directory not found at %s", templatesDir)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_EnvironmentVariables(t *testing.T) {
|
||||
t.Run("config_loading", func(t *testing.T) {
|
||||
envVars := []string{
|
||||
"SERVER_HOST",
|
||||
"SERVER_PORT",
|
||||
"DATABASE_HOST",
|
||||
"DATABASE_PORT",
|
||||
"DATABASE_USER",
|
||||
"DATABASE_PASSWORD",
|
||||
"DATABASE_NAME",
|
||||
"JWT_SECRET",
|
||||
}
|
||||
|
||||
for _, envVar := range envVars {
|
||||
if os.Getenv(envVar) == "" {
|
||||
t.Logf("Environment variable %s not set (this is expected in test environment)", envVar)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_BinaryExists(t *testing.T) {
|
||||
t.Run("binary_builds", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping binary build test in short mode")
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Skipf("Failed to get working directory: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
workspaceRoot := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(workspaceRoot, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(workspaceRoot)
|
||||
if parent == workspaceRoot {
|
||||
t.Skip("Could not find workspace root")
|
||||
return
|
||||
}
|
||||
workspaceRoot = parent
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "build", "-o", "/tmp/goyco-test", "./cmd/goyco")
|
||||
cmd.Dir = workspaceRoot
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Skipf("Failed to build binary: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat("/tmp/goyco-test"); os.IsNotExist(err) {
|
||||
t.Error("Binary was not created")
|
||||
} else {
|
||||
os.Remove("/tmp/goyco-test")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConfigurationValidation(t *testing.T) {
|
||||
t.Run("required_paths", func(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
workspaceRoot := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(workspaceRoot, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(workspaceRoot)
|
||||
if parent == workspaceRoot {
|
||||
t.Fatalf("Could not find workspace root (go.mod) starting from %s", wd)
|
||||
}
|
||||
workspaceRoot = parent
|
||||
}
|
||||
|
||||
requiredPaths := []string{
|
||||
"cmd/goyco",
|
||||
"internal",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
}
|
||||
|
||||
for _, path := range requiredPaths {
|
||||
fullPath := filepath.Join(workspaceRoot, path)
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
t.Errorf("Required path not found: %s (workspace root: %s)", path, workspaceRoot)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_PartialFailureHandling(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("partial_failure_handling", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "partial", "Password123!")
|
||||
|
||||
post := authClient.CreatePost(t, "Partial Failure Test", "https://example.com/partial", "Content")
|
||||
if post.ID == 0 {
|
||||
t.Fatalf("Expected post creation to succeed")
|
||||
}
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
foundPost := findPostInList(postsResp, post.ID)
|
||||
if foundPost == nil {
|
||||
t.Fatalf("Expected post to exist after creation")
|
||||
}
|
||||
|
||||
invalidPostID := uint(999999)
|
||||
voteResp, statusCode := authClient.VoteOnPostRaw(t, invalidPostID, "up")
|
||||
if statusCode == http.StatusOK || voteResp.Success {
|
||||
t.Errorf("Expected vote on non-existent post to fail")
|
||||
}
|
||||
|
||||
postsRespAfter := authClient.GetPosts(t)
|
||||
foundPostAfter := findPostInList(postsRespAfter, post.ID)
|
||||
if foundPostAfter == nil {
|
||||
t.Errorf("Expected post to still exist after vote failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentModification(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_modification", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "concmode1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "concmode2", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Concurrent Edit Test", "https://example.com/concmode", "Original content")
|
||||
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
|
||||
statusCode := client2.UpdatePostExpectStatus(t, post.ID, "Hacked Title", "https://example.com/concmode", "Hacked content")
|
||||
if statusCode != http.StatusForbidden {
|
||||
t.Errorf("Expected 403 Forbidden when user2 tries to edit user1's post, got %d", statusCode)
|
||||
}
|
||||
|
||||
postsResp := client1.GetPosts(t)
|
||||
updatedPost := findPostInList(postsResp, post.ID)
|
||||
if updatedPost == nil {
|
||||
t.Fatalf("Expected post to exist")
|
||||
}
|
||||
if updatedPost.Title != "Concurrent Edit Test" {
|
||||
t.Errorf("Expected post title to remain unchanged after unauthorized edit attempt, got '%s'", updatedPost.Title)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ResourceNotFound(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("resource_not_found", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "notfound", "Password123!")
|
||||
|
||||
post := authClient.CreatePost(t, "To Delete", "https://example.com/todelete", "Content")
|
||||
authClient.DeletePost(t, post.ID)
|
||||
|
||||
statusCode := authClient.UpdatePostExpectStatus(t, post.ID, "Updated", "https://example.com/todelete", "Updated")
|
||||
if statusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected 404 Not Found when accessing deleted post, got %d", statusCode)
|
||||
}
|
||||
|
||||
voteResp, statusCode := authClient.VoteOnPostRaw(t, post.ID, "up")
|
||||
if statusCode == http.StatusOK || voteResp.Success {
|
||||
t.Errorf("Expected vote on deleted post to fail")
|
||||
}
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
deletedPost := findPostInList(postsResp, post.ID)
|
||||
if deletedPost != nil {
|
||||
t.Errorf("Expected deleted post to not appear in posts list")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_InvalidStateTransitions(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("invalid_state_transitions", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "invalidstate", "Password123!")
|
||||
|
||||
post := authClient.CreatePost(t, "State Test", "https://example.com/state", "Content")
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, post.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed")
|
||||
}
|
||||
|
||||
authClient.DeletePost(t, post.ID)
|
||||
|
||||
voteRespAfter, statusCode := authClient.VoteOnPostRaw(t, post.ID, "down")
|
||||
if statusCode == http.StatusOK || voteRespAfter.Success {
|
||||
t.Errorf("Expected vote on deleted post to fail")
|
||||
}
|
||||
|
||||
statusCode = authClient.UpdatePostExpectStatus(t, post.ID, "Updated", "https://example.com/state", "Updated")
|
||||
if statusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected 404 when updating deleted post, got %d", statusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RequestTimeoutHandling(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("request_timeout_handling", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "timeout", "Password123!")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 1 * time.Nanosecond,
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
testutils.WithStandardHeaders(request)
|
||||
|
||||
_, err = client.Do(request)
|
||||
if err == nil {
|
||||
t.Log("Request completed despite timeout (acceptable if server is very fast)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SlowResponseHandling(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("slow_response_handling", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "slow", "Password123!")
|
||||
|
||||
start := time.Now()
|
||||
postsResp := authClient.GetPosts(t)
|
||||
duration := time.Since(start)
|
||||
|
||||
if postsResp == nil {
|
||||
t.Errorf("Expected posts response even with slow response")
|
||||
}
|
||||
|
||||
if duration > 30*time.Second {
|
||||
t.Errorf("Request took too long: %v", duration)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_MalformedInput(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("malformed_input", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "malformed", "Password123!")
|
||||
|
||||
t.Run("very_long_title", func(t *testing.T) {
|
||||
longTitle := make([]byte, 201)
|
||||
for i := range longTitle {
|
||||
longTitle[i] = 'A'
|
||||
}
|
||||
|
||||
postData := map[string]string{
|
||||
"title": string(longTitle),
|
||||
"url": "https://example.com/long",
|
||||
}
|
||||
body, _ := json.Marshal(postData)
|
||||
|
||||
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
testutils.WithStandardHeaders(request)
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
t.Errorf("Expected long title to be rejected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("very_long_content", func(t *testing.T) {
|
||||
longContent := make([]byte, 10001)
|
||||
for i := range longContent {
|
||||
longContent[i] = 'B'
|
||||
}
|
||||
|
||||
postData := map[string]string{
|
||||
"title": "Test",
|
||||
"url": "https://example.com/longcontent",
|
||||
"content": string(longContent),
|
||||
}
|
||||
body, _ := json.Marshal(postData)
|
||||
|
||||
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
testutils.WithStandardHeaders(request)
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
t.Errorf("Expected long content to be rejected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("special_characters", func(t *testing.T) {
|
||||
specialChars := []string{
|
||||
"<script>alert('XSS')</script>",
|
||||
"'; DROP TABLE posts; --",
|
||||
"测试中文",
|
||||
"🚀 Emoji Test",
|
||||
"Test\nNewline",
|
||||
"Test\tTab",
|
||||
}
|
||||
|
||||
for _, special := range specialChars {
|
||||
postData := map[string]string{
|
||||
"title": special,
|
||||
"url": "https://example.com/special",
|
||||
"content": special,
|
||||
}
|
||||
body, _ := json.Marshal(postData)
|
||||
|
||||
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
testutils.WithStandardHeaders(request)
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
postsResp := authClient.GetPosts(t)
|
||||
if postsResp != nil {
|
||||
t.Logf("Special characters accepted: %s (may be sanitized)", special)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing_required_fields", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"missing_url", map[string]any{"title": "Test"}},
|
||||
{"empty_url", map[string]any{"title": "Test", "url": ""}},
|
||||
{"missing_title_and_url", map[string]any{"content": "Content"}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(tc.body)
|
||||
|
||||
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
testutils.WithStandardHeaders(request)
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
t.Errorf("Expected missing required fields to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong_data_types", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"title_as_number", `{"title": 123, "url": "https://example.com"}`},
|
||||
{"url_as_boolean", `{"title": "Test", "url": true}`},
|
||||
{"content_as_array", `{"title": "Test", "url": "https://example.com", "content": []}`},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader([]byte(tc.body)))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
testutils.WithStandardHeaders(request)
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
t.Errorf("Expected wrong data types to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentVotes(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_votes", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "concvote1", "Password123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "concvote2", "Password123!")
|
||||
user3 := ctx.createUserWithCleanup(t, "concvote3", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
post := client1.CreatePost(t, "Concurrent Vote Test", "https://example.com/concvote", "Content")
|
||||
|
||||
client2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
client3 := ctx.loginUser(t, user3.Username, user3.Password)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan bool, 3)
|
||||
|
||||
wg.Add(3)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voteResp := client1.VoteOnPost(t, post.ID, "up")
|
||||
results <- voteResp.Success
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voteResp := client2.VoteOnPost(t, post.ID, "up")
|
||||
results <- voteResp.Success
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voteResp := client3.VoteOnPost(t, post.ID, "down")
|
||||
results <- voteResp.Success
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
successCount := 0
|
||||
for success := range results {
|
||||
if success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
t.Errorf("Expected at least some concurrent votes to succeed")
|
||||
}
|
||||
|
||||
var dbPost database.Post
|
||||
if err := ctx.server.DB.First(&dbPost, post.ID).Error; err != nil {
|
||||
t.Fatalf("Failed to find post in database: %v", err)
|
||||
}
|
||||
|
||||
if dbPost.UpVotes+dbPost.DownVotes != successCount {
|
||||
t.Logf("Vote counts may not match exactly due to race conditions (acceptable)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentPostCreation(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_post_creation", func(t *testing.T) {
|
||||
users := ctx.createMultipleUsersWithCleanup(t, 5, "concpost", "Password123!")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan *TestPost, len(users))
|
||||
var mu sync.Mutex
|
||||
createdURLs := make(map[string]bool)
|
||||
|
||||
for _, user := range users {
|
||||
u := user
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
client, err := ctx.loginUserSafe(t, u.Username, u.Password)
|
||||
if err != nil || client == nil {
|
||||
results <- nil
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://example.com/concpost/%d", u.ID)
|
||||
mu.Lock()
|
||||
if createdURLs[url] {
|
||||
mu.Unlock()
|
||||
results <- nil
|
||||
return
|
||||
}
|
||||
createdURLs[url] = true
|
||||
mu.Unlock()
|
||||
|
||||
post, err := client.CreatePostSafe("Concurrent Post", url, "Content")
|
||||
results <- post
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
successCount := 0
|
||||
for post := range results {
|
||||
if post != nil && post.ID != 0 {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
t.Errorf("Expected at least some concurrent post creations to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentProfileUpdates(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_profile_updates", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "concprofile", "Password123!")
|
||||
|
||||
client1 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
client2 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan bool, 2)
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
newUsername := uniqueUsername(t, "update1")
|
||||
client1.UpdateUsername(t, newUsername)
|
||||
profile := client1.GetProfile(t)
|
||||
results <- (profile != nil && profile.Data.Username == newUsername)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
newUsername := uniqueUsername(t, "update2")
|
||||
client2.UpdateUsername(t, newUsername)
|
||||
profile := client2.GetProfile(t)
|
||||
results <- (profile != nil && profile.Data.Username == newUsername)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
successCount := 0
|
||||
for success := range results {
|
||||
if success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
t.Errorf("Expected at least some concurrent profile updates to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_DatabaseFailureRecovery(t *testing.T) {
|
||||
t.Run("database_unavailable_handles_gracefully", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
sqlDB, err := ctx.server.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get SQL DB: %v", err)
|
||||
}
|
||||
sqlDB.Close()
|
||||
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError && resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Logf("Expected 500 or 503, got %d (acceptable for unavailable DB)", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("connection_pool_exhaustion", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
sqlDB, err := ctx.server.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get SQL DB: %v", err)
|
||||
}
|
||||
|
||||
originalMaxOpen := sqlDB.Stats().MaxOpenConnections
|
||||
if originalMaxOpen == 0 {
|
||||
originalMaxOpen = 1
|
||||
}
|
||||
|
||||
sqlDB.SetMaxOpenConns(2)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 10)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conn, err := sqlDB.Conn(context.Background())
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
errorCount := 0
|
||||
for range errors {
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if errorCount == 0 {
|
||||
t.Log("No connection errors occurred (pool handled load)")
|
||||
}
|
||||
|
||||
sqlDB.SetMaxOpenConns(int(originalMaxOpen))
|
||||
})
|
||||
|
||||
t.Run("transaction_rollback_on_error", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
testUser := ctx.createUserWithCleanup(t, "rollbackuser", "StrongPass123!")
|
||||
|
||||
tx := ctx.server.DB.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("Failed to begin transaction: %v", tx.Error)
|
||||
}
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Rollback Test Post",
|
||||
URL: "https://example.com/rollback",
|
||||
Content: "This post should be rolled back",
|
||||
AuthorID: &testUser.ID,
|
||||
}
|
||||
|
||||
err := tx.Create(post).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("Failed to create post in transaction: %v", err)
|
||||
}
|
||||
|
||||
var postInTx database.Post
|
||||
err = tx.First(&postInTx, post.ID).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("Failed to retrieve post in transaction: %v", err)
|
||||
}
|
||||
|
||||
tx.Rollback()
|
||||
|
||||
var postAfterRollback database.Post
|
||||
err = ctx.server.DB.First(&postAfterRollback, post.ID).Error
|
||||
if err == nil {
|
||||
t.Error("Expected post to not exist after transaction rollback")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Logf("Post correctly not found after rollback (error: %v)", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transaction_commit_succeeds", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
testUser := ctx.createUserWithCleanup(t, "commituser", "StrongPass123!")
|
||||
|
||||
tx := ctx.server.DB.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("Failed to begin transaction: %v", tx.Error)
|
||||
}
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Commit Test Post",
|
||||
URL: "https://example.com/commit",
|
||||
Content: "This post should be committed",
|
||||
AuthorID: &testUser.ID,
|
||||
}
|
||||
|
||||
err := tx.Create(post).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("Failed to create post in transaction: %v", err)
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to commit transaction: %v", err)
|
||||
}
|
||||
|
||||
var postAfterCommit database.Post
|
||||
err = ctx.server.DB.First(&postAfterCommit, post.ID).Error
|
||||
if err != nil {
|
||||
t.Errorf("Expected post to exist after transaction commit, got error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("database_timeout_handling", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
sqlDB, err := ctx.server.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get SQL DB: %v", err)
|
||||
}
|
||||
|
||||
ctxTimeout, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
|
||||
conn, err := sqlDB.Conn(ctxTimeout)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
rows, err := conn.QueryContext(ctxTimeout, "SELECT 1")
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Logf("Timeout handled correctly: %v", err)
|
||||
}
|
||||
if rows != nil {
|
||||
rows.Close()
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent_transaction_isolation", func(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
testUser := ctx.createUserWithCleanup(t, "isolationuser", "StrongPass123!")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 2)
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tx1 := ctx.server.DB.Begin()
|
||||
if tx1.Error != nil {
|
||||
errors <- tx1.Error
|
||||
return
|
||||
}
|
||||
|
||||
post1 := &database.Post{
|
||||
Title: "Isolation Post 1",
|
||||
URL: "https://example.com/isolation1",
|
||||
Content: "First transaction",
|
||||
AuthorID: &testUser.ID,
|
||||
}
|
||||
|
||||
err := tx1.Create(post1).Error
|
||||
if err != nil {
|
||||
tx1.Rollback()
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
tx1.Commit()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
|
||||
tx2 := ctx.server.DB.Begin()
|
||||
if tx2.Error != nil {
|
||||
errors <- tx2.Error
|
||||
return
|
||||
}
|
||||
|
||||
post2 := &database.Post{
|
||||
Title: "Isolation Post 2",
|
||||
URL: "https://example.com/isolation2",
|
||||
Content: "Second transaction",
|
||||
AuthorID: &testUser.ID,
|
||||
}
|
||||
|
||||
err := tx2.Create(post2).Error
|
||||
if err != nil {
|
||||
tx2.Rollback()
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
|
||||
tx2.Commit()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
for err := range errors {
|
||||
if err != nil {
|
||||
t.Errorf("Transaction error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_DatabaseConnectionPool(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("pool_stats_tracking", func(t *testing.T) {
|
||||
sqlDB, err := ctx.server.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get SQL DB: %v", err)
|
||||
}
|
||||
|
||||
stats := sqlDB.Stats()
|
||||
if stats.MaxOpenConnections == 0 {
|
||||
t.Error("Expected MaxOpenConnections to be set")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
newStats := sqlDB.Stats()
|
||||
if newStats.OpenConnections > stats.OpenConnections {
|
||||
t.Logf("Connection pool used: %d -> %d connections", stats.OpenConnections, newStats.OpenConnections)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pool_reuses_connections", func(t *testing.T) {
|
||||
sqlDB, err := ctx.server.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get SQL DB: %v", err)
|
||||
}
|
||||
|
||||
initialStats := sqlDB.Stats()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
finalStats := sqlDB.Stats()
|
||||
if finalStats.OpenConnections > initialStats.MaxOpenConnections {
|
||||
t.Errorf("Pool exceeded max connections: %d > %d", finalStats.OpenConnections, initialStats.MaxOpenConnections)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_DatabaseErrorHandling(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("invalid_query_returns_error", func(t *testing.T) {
|
||||
var result struct {
|
||||
ID int
|
||||
}
|
||||
|
||||
err := ctx.server.DB.Raw("SELECT * FROM nonexistent_table WHERE id = ?", 1).Scan(&result).Error
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid query")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("constraint_violation_handled", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "constraintuser", "StrongPass123!")
|
||||
|
||||
duplicateUser := &database.User{
|
||||
Username: testUser.Username,
|
||||
Email: "different@example.com",
|
||||
Password: "DifferentPass123!",
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
err := ctx.server.DB.Create(duplicateUser).Error
|
||||
if err == nil {
|
||||
t.Error("Expected error for duplicate username")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("null_constraint_violation", func(t *testing.T) {
|
||||
invalidPost := &database.Post{
|
||||
Title: "",
|
||||
URL: "",
|
||||
Content: "",
|
||||
}
|
||||
|
||||
err := ctx.server.DB.Create(invalidPost).Error
|
||||
if err == nil {
|
||||
t.Log("SQLite allows empty strings (constraint validation handled at application level)")
|
||||
} else {
|
||||
t.Logf("Database rejected empty values: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_CompressionMiddleware(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("compression_enabled_with_accept_encoding", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentEncoding := resp.Header.Get("Content-Encoding")
|
||||
if contentEncoding == "gzip" {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
if isGzipCompressed(body) {
|
||||
reader, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decompress: %v", err)
|
||||
}
|
||||
|
||||
if len(decompressed) == 0 {
|
||||
t.Error("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) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentEncoding := resp.Header.Get("Content-Encoding")
|
||||
if contentEncoding == "gzip" {
|
||||
t.Error("Expected no compression without Accept-Encoding header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decompression_handles_gzip_request", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "compressionuser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
postData := `{"title":"Compressed Post","url":"https://example.com/compressed","content":"Test content"}`
|
||||
gz.Write([]byte(postData))
|
||||
gz.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Content-Encoding", "gzip")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
t.Log("Decompression middleware rejected invalid gzip")
|
||||
} else if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
||||
t.Log("Decompression middleware handled gzip request successfully")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_CacheMiddleware(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("cache_miss_then_hit", func(t *testing.T) {
|
||||
req1, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req1)
|
||||
|
||||
resp1, err := ctx.client.Do(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp1.Body.Close()
|
||||
|
||||
cacheStatus1 := resp1.Header.Get("X-Cache")
|
||||
if cacheStatus1 == "HIT" {
|
||||
t.Log("First request was cached (unexpected but acceptable)")
|
||||
}
|
||||
|
||||
req2, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req2)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
cacheStatus2 := resp2.Header.Get("X-Cache")
|
||||
if cacheStatus2 == "HIT" {
|
||||
t.Log("Second request was served from cache")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cache_invalidation_on_post", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "cacheuser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||
|
||||
req1, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req1)
|
||||
req1.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp1, err := ctx.client.Do(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp1.Body.Close()
|
||||
|
||||
postData := `{"title":"Cache Invalidation Test","url":"https://example.com/cache","content":"Test"}`
|
||||
req2, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", strings.NewReader(postData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req2)
|
||||
req2.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
req3, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req3)
|
||||
req3.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp3, err := ctx.client.Do(req3)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp3.Body.Close()
|
||||
|
||||
cacheStatus := resp3.Header.Get("X-Cache")
|
||||
if cacheStatus == "HIT" {
|
||||
t.Log("Cache was invalidated after POST")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_CSRFProtection(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("csrf_protection_for_non_api_routes", func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/auth/login", strings.NewReader(`{"username":"test","password":"test"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
t.Log("CSRF protection active for non-API routes")
|
||||
} else {
|
||||
t.Logf("CSRF check result: status %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csrf_bypass_for_api_routes", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "csrfuser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||
|
||||
postData := `{"title":"CSRF Test","url":"https://example.com/csrf","content":"Test"}`
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", strings.NewReader(postData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
t.Error("API routes should bypass CSRF protection")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csrf_allows_get_requests", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/auth/login", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
t.Error("GET requests should not require CSRF token")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RequestSizeLimit(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("request_within_size_limit", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sizelimituser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||
|
||||
smallData := strings.Repeat("a", 100)
|
||||
postData := `{"title":"` + smallData + `","url":"https://example.com","content":"test"}`
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", strings.NewReader(postData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusRequestEntityTooLarge {
|
||||
t.Error("Small request should not exceed size limit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("request_exceeds_size_limit", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sizelimituser2", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, testUser.Username, "StrongPass123!")
|
||||
|
||||
largeData := strings.Repeat("a", 2*1024*1024)
|
||||
postData := `{"title":"test","url":"https://example.com","content":"` + largeData + `"}`
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", strings.NewReader(postData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusRequestEntityTooLarge {
|
||||
t.Log("Request size limit enforced correctly")
|
||||
} else {
|
||||
t.Logf("Request size limit check result: status %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isGzipCompressed(data []byte) bool {
|
||||
return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_Performance(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("response_times", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "perfuser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
req func() (*http.Request, error)
|
||||
}{
|
||||
{
|
||||
name: "health",
|
||||
req: func() (*http.Request, error) {
|
||||
return http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "posts_list",
|
||||
req: func() (*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err == nil {
|
||||
testutils.WithStandardHeaders(req)
|
||||
}
|
||||
return req, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "profile",
|
||||
req: func() (*http.Request, error) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/auth/me", nil)
|
||||
if err == nil {
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
testutils.WithStandardHeaders(req)
|
||||
}
|
||||
return req, err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint.name, func(t *testing.T) {
|
||||
var totalTime time.Duration
|
||||
iterations := 10
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
req, err := endpoint.req()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := ctx.client.Do(req)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
totalTime += duration
|
||||
}
|
||||
|
||||
avgTime := totalTime / time.Duration(iterations)
|
||||
if avgTime > 500*time.Millisecond {
|
||||
t.Errorf("Average response time %v exceeds 500ms", avgTime)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent_requests", func(t *testing.T) {
|
||||
ctx.createUserWithCleanup(t, "concurrentperf", "StrongPass123!")
|
||||
|
||||
concurrency := 20
|
||||
requestsPerGoroutine := 5
|
||||
var successCount int64
|
||||
var errorCount int64
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < requestsPerGoroutine; j++ {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
continue
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
atomic.AddInt64(&successCount, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
totalRequests := int64(concurrency * requestsPerGoroutine)
|
||||
if successCount < totalRequests*8/10 {
|
||||
t.Errorf("Expected at least 80%% success rate, got %d/%d successful", successCount, totalRequests)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("database_query_performance", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "dbperf", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
authClient.CreatePost(t, fmt.Sprintf("Post %d", i), fmt.Sprintf("https://example.com/%d", i), "Content")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
postsResp := authClient.GetPosts(t)
|
||||
duration := time.Since(start)
|
||||
|
||||
if len(postsResp.Data.Posts) < 10 {
|
||||
t.Errorf("Expected at least 10 posts, got %d", len(postsResp.Data.Posts))
|
||||
}
|
||||
|
||||
if duration > 1*time.Second {
|
||||
t.Errorf("Posts query took %v, expected under 1s", duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("memory_usage", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "memuser", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
initialPosts := 50
|
||||
for i := 0; i < initialPosts; i++ {
|
||||
authClient.CreatePost(t, fmt.Sprintf("Memory Test Post %d", i), fmt.Sprintf("https://example.com/mem%d", i), "Content")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts?limit=100", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var postsResp testutils.PostsListResponse
|
||||
reader := resp.Body
|
||||
if resp.Header.Get("Content-Encoding") == "gzip" {
|
||||
gzReader, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
reader = gzReader
|
||||
}
|
||||
if err := json.NewDecoder(reader).Decode(&postsResp); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(postsResp.Data.Posts) < initialPosts {
|
||||
t.Errorf("Expected at least %d posts, got %d", initialPosts, len(postsResp.Data.Posts))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_LoadTest(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("sustained_load", func(t *testing.T) {
|
||||
ctx.createUserWithCleanup(t, "loaduser", "StrongPass123!")
|
||||
|
||||
duration := 5 * time.Second
|
||||
requestsPerSecond := 10
|
||||
ticker := time.NewTicker(time.Second / time.Duration(requestsPerSecond))
|
||||
defer ticker.Stop()
|
||||
|
||||
var successCount int64
|
||||
var errorCount int64
|
||||
done := make(chan bool)
|
||||
|
||||
go func() {
|
||||
time.Sleep(duration)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
totalRequests := successCount + errorCount
|
||||
if totalRequests == 0 {
|
||||
t.Error("No requests were made")
|
||||
return
|
||||
}
|
||||
successRate := float64(successCount) / float64(totalRequests)
|
||||
if successRate < 0.9 {
|
||||
t.Errorf("Success rate %.2f%% below 90%% threshold", successRate*100)
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
go func() {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
return
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
atomic.AddInt64(&successCount, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentWrites(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_post_creation", func(t *testing.T) {
|
||||
users := ctx.createMultipleUsersWithCleanup(t, 5, "writeuser", "StrongPass123!")
|
||||
var wg sync.WaitGroup
|
||||
var successCount int64
|
||||
var errorCount int64
|
||||
|
||||
for _, user := range users {
|
||||
u := user
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
authClient, err := ctx.loginUserSafe(t, u.Username, u.Password)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
post, err := authClient.CreatePostSafe(
|
||||
fmt.Sprintf("Concurrent Post %d", i),
|
||||
fmt.Sprintf("https://example.com/concurrent%d-%d", u.ID, i),
|
||||
"Content",
|
||||
)
|
||||
if err == nil && post != nil {
|
||||
atomic.AddInt64(&successCount, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
expectedPosts := int64(len(users) * 5)
|
||||
if successCount < expectedPosts*7/10 {
|
||||
t.Errorf("Expected at least 70%% success rate, got %d/%d successful (errors: %d)", successCount, expectedPosts, errorCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ResponseSize(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("large_response", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "sizetest", "StrongPass123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
authClient.CreatePost(t, fmt.Sprintf("Post %d", i), fmt.Sprintf("https://example.com/%d", i), "Content")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(resp.Body)
|
||||
responseSize := buf.Len()
|
||||
|
||||
if responseSize > 10*1024*1024 {
|
||||
t.Errorf("Response size %d bytes exceeds 10MB limit", responseSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_Throughput(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("requests_per_second", func(t *testing.T) {
|
||||
ctx.createUserWithCleanup(t, "throughput", "StrongPass123!")
|
||||
|
||||
duration := 3 * time.Second
|
||||
start := time.Now()
|
||||
var requestCount int64
|
||||
|
||||
for time.Since(start) < duration {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api/posts", nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
atomic.AddInt64(&requestCount, 1)
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
rps := float64(requestCount) / elapsed.Seconds()
|
||||
|
||||
if rps < 10 {
|
||||
t.Errorf("Throughput %.2f req/s below 10 req/s threshold", rps)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestE2E_PostManagement(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("post_crud_operations", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
createdPost := authClient.CreatePost(t, "Original Post", "https://example.com/original", "Original content")
|
||||
updatedPost := authClient.UpdatePost(t, createdPost.ID, "Updated Post", "https://example.com/updated", "Updated content")
|
||||
|
||||
if updatedPost.Title != "Updated Post" {
|
||||
t.Errorf("Expected updated title 'Updated Post', got '%s'", updatedPost.Title)
|
||||
}
|
||||
if updatedPost.Content != "Updated content" {
|
||||
t.Errorf("Expected updated content 'Updated content', got '%s'", updatedPost.Content)
|
||||
}
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
assertPostInList(t, postsResp, updatedPost)
|
||||
|
||||
authClient.DeletePost(t, createdPost.ID)
|
||||
|
||||
finalPostsResp := authClient.GetPosts(t)
|
||||
if len(finalPostsResp.Data.Posts) > 0 {
|
||||
for _, post := range finalPostsResp.Data.Posts {
|
||||
if post.ID == createdPost.ID {
|
||||
t.Errorf("Expected post to be deleted, but it still appears in posts list")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PostOwnershipAuthorization(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("post_ownership_authorization", func(t *testing.T) {
|
||||
createdUsers := ctx.createMultipleUsersWithCleanup(t, 2, "user", "StrongPass123!")
|
||||
user1 := createdUsers[0]
|
||||
user2 := createdUsers[1]
|
||||
|
||||
authClient1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
createdPost := authClient1.CreatePost(t, "User1's Post", "https://example.com/user1", "This is user1's post content")
|
||||
|
||||
authClient2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
|
||||
t.Run("user2_cannot_update_user1_post", func(t *testing.T) {
|
||||
statusCode := authClient2.UpdatePostExpectStatus(t, createdPost.ID, "Hacked Title", "https://evil.com", "Hacked content")
|
||||
if statusCode != http.StatusForbidden {
|
||||
t.Errorf("Expected 403 Forbidden when User2 tries to update User1's post, got %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user2_cannot_delete_user1_post", func(t *testing.T) {
|
||||
statusCode := authClient2.DeletePostExpectStatus(t, createdPost.ID)
|
||||
if statusCode != http.StatusForbidden {
|
||||
t.Errorf("Expected 403 Forbidden when User2 tries to delete User1's post, got %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user1_post_unchanged", func(t *testing.T) {
|
||||
postsResp := authClient1.GetPosts(t)
|
||||
found := false
|
||||
for _, post := range postsResp.Data.Posts {
|
||||
if post.ID == createdPost.ID {
|
||||
found = true
|
||||
if post.Title != createdPost.Title {
|
||||
t.Errorf("Expected post title to remain '%s', but it was modified to '%s'", createdPost.Title, post.Title)
|
||||
}
|
||||
if post.Content != createdPost.Content {
|
||||
t.Errorf("Expected post content to remain unchanged, but it was modified")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected User1's post to still exist, but it was not found in the posts list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user1_can_update_own_post", func(t *testing.T) {
|
||||
updatedPost := authClient1.UpdatePost(t, createdPost.ID, "Updated by User1", "https://example.com/updated", "Updated content by User1")
|
||||
if updatedPost.Title != "Updated by User1" {
|
||||
t.Errorf("Expected post title to be 'Updated by User1', got '%s'", updatedPost.Title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user1_can_delete_own_post", func(t *testing.T) {
|
||||
deletablePost := authClient1.CreatePost(t, "Deletable Post", "https://example.com/deletable", "This post will be deleted")
|
||||
authClient1.DeletePost(t, deletablePost.ID)
|
||||
|
||||
postsResp := authClient1.GetPosts(t)
|
||||
for _, post := range postsResp.Data.Posts {
|
||||
if post.ID == deletablePost.ID {
|
||||
t.Errorf("Expected post %d to be deleted, but it still exists", deletablePost.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_RateLimitingHeaders(t *testing.T) {
|
||||
ctx := setupTestContextWithAuthRateLimit(t, 3)
|
||||
|
||||
t.Run("rate_limit_headers_present", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "ratelimituser", "StrongPass123!")
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("X-Forwarded-For", testutils.GenerateTestIP())
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
if retryAfter == "" {
|
||||
t.Error("Expected Retry-After header when rate limited")
|
||||
}
|
||||
|
||||
var jsonResponse map[string]interface{}
|
||||
body, _ := json.Marshal(map[string]string{})
|
||||
_ = json.Unmarshal(body, &jsonResponse)
|
||||
|
||||
if resp.Header.Get("Content-Type") != "application/json" {
|
||||
t.Error("Expected Content-Type to be application/json")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rate_limit_exceeded_response", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "ratelimituser2", "StrongPass123!")
|
||||
testIP := testutils.GenerateTestIP()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("X-Forwarded-For", testIP)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if i >= 3 {
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Errorf("Expected status 429 on request %d, got %d", i+1, resp.StatusCode)
|
||||
} else {
|
||||
var errorResponse map[string]interface{}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if err := json.Unmarshal(body, &errorResponse); err == nil {
|
||||
if errorResponse["error"] == nil {
|
||||
t.Error("Expected error field in rate limit response")
|
||||
}
|
||||
if errorResponse["retry_after"] == nil {
|
||||
t.Error("Expected retry_after field in rate limit response")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RateLimitResetBehavior(t *testing.T) {
|
||||
ctx := setupTestContextWithAuthRateLimit(t, 2)
|
||||
|
||||
t.Run("rate_limit_resets_after_window", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "resetuser", "StrongPass123!")
|
||||
testIP := testutils.GenerateTestIP()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("X-Forwarded-For", testIP)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req)
|
||||
req.Header.Set("X-Forwarded-For", testIP)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
t.Log("Rate limit correctly enforced")
|
||||
}
|
||||
|
||||
ctx.assertEventually(t, func() bool {
|
||||
req2, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req2)
|
||||
req2.Header.Set("X-Forwarded-For", testIP)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
return resp2.StatusCode == http.StatusOK || resp2.StatusCode == http.StatusUnauthorized
|
||||
}, 70*time.Second)
|
||||
|
||||
req2, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req2)
|
||||
req2.Header.Set("X-Forwarded-For", testIP)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusOK || resp2.StatusCode == http.StatusUnauthorized {
|
||||
t.Log("Rate limit reset after window")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RateLimitDifferentScenarios(t *testing.T) {
|
||||
ctx := setupTestContextWithAuthRateLimit(t, 5)
|
||||
|
||||
t.Run("different_ips_have_separate_limits", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "multiuser", "StrongPass123!")
|
||||
|
||||
ip1 := testutils.GenerateTestIP()
|
||||
ip2 := testutils.GenerateTestIP()
|
||||
|
||||
successCount1 := 0
|
||||
successCount2 := 0
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
req1, _ := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
req1.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req1)
|
||||
req1.Header.Set("X-Forwarded-For", ip1)
|
||||
|
||||
resp1, err := ctx.client.Do(req1)
|
||||
if err == nil {
|
||||
if resp1.StatusCode == http.StatusOK || resp1.StatusCode == http.StatusUnauthorized {
|
||||
successCount1++
|
||||
}
|
||||
resp1.Body.Close()
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest("POST", ctx.baseURL+"/api/auth/login", strings.NewReader(`{"username":"`+testUser.Username+`","password":"StrongPass123!"}`))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(req2)
|
||||
req2.Header.Set("X-Forwarded-For", ip2)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err == nil {
|
||||
if resp2.StatusCode == http.StatusOK || resp2.StatusCode == http.StatusUnauthorized {
|
||||
successCount2++
|
||||
}
|
||||
resp2.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if successCount1 > 0 && successCount2 > 0 {
|
||||
t.Log("Different IPs have separate rate limits")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("authenticated_users_have_separate_limits", func(t *testing.T) {
|
||||
user1 := ctx.createUserWithCleanup(t, "authuser1", "StrongPass123!")
|
||||
user2 := ctx.createUserWithCleanup(t, "authuser2", "StrongPass123!")
|
||||
|
||||
authClient1 := ctx.loginUser(t, user1.Username, "StrongPass123!")
|
||||
authClient2 := ctx.loginUser(t, user2.Username, "StrongPass123!")
|
||||
|
||||
successCount1 := 0
|
||||
successCount2 := 0
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
req1, _ := http.NewRequest("GET", ctx.baseURL+"/api/auth/me", nil)
|
||||
testutils.WithStandardHeaders(req1)
|
||||
req1.Header.Set("Authorization", "Bearer "+authClient1.Token)
|
||||
|
||||
resp1, err := ctx.client.Do(req1)
|
||||
if err == nil {
|
||||
if resp1.StatusCode == http.StatusOK {
|
||||
successCount1++
|
||||
}
|
||||
resp1.Body.Close()
|
||||
}
|
||||
|
||||
req2, _ := http.NewRequest("GET", ctx.baseURL+"/api/auth/me", nil)
|
||||
testutils.WithStandardHeaders(req2)
|
||||
req2.Header.Set("Authorization", "Bearer "+authClient2.Token)
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err == nil {
|
||||
if resp2.StatusCode == http.StatusOK {
|
||||
successCount2++
|
||||
}
|
||||
resp2.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if successCount1 > 5 && successCount2 > 5 {
|
||||
t.Log("Authenticated users have separate rate limits")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_RobotsTxt(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("robots_txt_served", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/robots.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for robots.txt, got %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/plain") && !strings.Contains(contentType, "text") {
|
||||
t.Logf("Unexpected Content-Type for robots.txt: %s", contentType)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read robots.txt body: %v", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
if len(content) == 0 {
|
||||
t.Error("robots.txt is empty")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.Contains(content, "User-agent") {
|
||||
t.Error("robots.txt missing User-agent directive")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("robots_txt_content_validation", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/robots.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("robots.txt not available")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read robots.txt body: %v", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
hasUserAgent := false
|
||||
hasDisallow := false
|
||||
hasAllow := false
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "User-agent:") {
|
||||
hasUserAgent = true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "Disallow:") {
|
||||
hasDisallow = true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "Allow:") {
|
||||
hasAllow = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUserAgent {
|
||||
t.Error("robots.txt missing User-agent directive")
|
||||
}
|
||||
|
||||
if !hasDisallow && !hasAllow {
|
||||
t.Log("robots.txt missing Allow/Disallow directives (may be intentional)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("robots_txt_api_disallowed", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/robots.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("robots.txt not available")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read robots.txt body: %v", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
if strings.Contains(content, "Disallow: /api/") {
|
||||
t.Log("robots.txt correctly disallows /api/")
|
||||
} else {
|
||||
t.Log("robots.txt may not explicitly disallow /api/")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("robots_txt_health_allowed", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/robots.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("robots.txt not available")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read robots.txt body: %v", err)
|
||||
}
|
||||
|
||||
content := string(body)
|
||||
if strings.Contains(content, "Allow: /health") {
|
||||
t.Log("robots.txt correctly allows /health")
|
||||
} else {
|
||||
t.Log("robots.txt may not explicitly allow /health")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/config"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_SessionFixation(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("session_fixation", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "sessionfix", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
oldToken := authClient.Token
|
||||
oldRefreshToken := authClient.RefreshToken
|
||||
|
||||
authClient.UpdatePassword(t, "Password123!", "NewPassword456!")
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, oldToken)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected old token to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldClient := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: oldToken,
|
||||
RefreshToken: oldRefreshToken,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldClient.RefreshAccessToken(t)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Errorf("Expected old refresh token to be invalidated after password change, but refresh succeeded")
|
||||
}
|
||||
|
||||
newAuthClient := ctx.loginUser(t, createdUser.Username, "NewPassword456!")
|
||||
if newAuthClient.Token == "" {
|
||||
t.Errorf("Expected to be able to login with new password")
|
||||
}
|
||||
|
||||
profile := newAuthClient.GetProfile(t)
|
||||
if profile.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected to access profile with new token, got username '%s'", profile.Data.Username)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenInvalidationOnPasswordChange(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_invalidation_on_password_change", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "tokeninv", "Password123!")
|
||||
|
||||
authClient1 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
token1 := authClient1.Token
|
||||
refreshToken1 := authClient1.RefreshToken
|
||||
|
||||
authClient2 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
token2 := authClient2.Token
|
||||
refreshToken2 := authClient2.RefreshToken
|
||||
|
||||
authClient3 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
token3 := authClient3.Token
|
||||
refreshToken3 := authClient3.RefreshToken
|
||||
|
||||
profile1 := authClient1.GetProfile(t)
|
||||
if profile1.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected token1 to work before password change")
|
||||
}
|
||||
|
||||
authClient1.UpdatePassword(t, "Password123!", "NewPassword789!")
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, token1)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected token1 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
statusCode = ctx.makeRequestWithToken(t, token2)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected token2 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
statusCode = ctx.makeRequestWithToken(t, token3)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected token3 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldClient1 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: token1,
|
||||
RefreshToken: refreshToken1,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldClient1.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refreshToken1 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldClient2 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: token2,
|
||||
RefreshToken: refreshToken2,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldClient2.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refreshToken2 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldClient3 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: token3,
|
||||
RefreshToken: refreshToken3,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldClient3.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refreshToken3 to be invalidated after password change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
newAuthClient := ctx.loginUser(t, createdUser.Username, "NewPassword789!")
|
||||
if newAuthClient.Token == "" {
|
||||
t.Errorf("Expected to be able to login with new password")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenInvalidationOnEmailChange(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_invalidation_on_email_change", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "emailchange", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
oldToken := authClient.Token
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
authClient.UpdateEmail(t, uniqueEmail(t, "newemail"))
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, oldToken)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Email change does not invalidate tokens (acceptable behavior)")
|
||||
}
|
||||
|
||||
_, statusCode = authClient.RefreshAccessToken(t)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Email change does not invalidate refresh tokens (acceptable behavior)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SessionVersionIncrements(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("session_version_increments", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "sessionver", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
initialVersion := user.SessionVersion
|
||||
if initialVersion == 0 {
|
||||
t.Errorf("Expected initial session version to be >= 1, got %d", initialVersion)
|
||||
}
|
||||
|
||||
authClient.UpdatePassword(t, "Password123!", "NewPassword999!")
|
||||
|
||||
user, err = ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user after password change: %v", err)
|
||||
}
|
||||
|
||||
if user.SessionVersion <= initialVersion {
|
||||
t.Errorf("Expected session version to increment after password change, got %d (was %d)", user.SessionVersion, initialVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_OldTokensRejectedAfterSessionVersionChange(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("old_tokens_rejected_after_session_version_change", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "oldtoken", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
oldSessionVersion := user.SessionVersion
|
||||
oldToken := authClient.Token
|
||||
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-for-testing-purposes-only",
|
||||
Expiration: 24,
|
||||
RefreshExpiration: 168,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
}
|
||||
|
||||
authClient.UpdatePassword(t, "Password123!", "NewPassword888!")
|
||||
|
||||
user, err = ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user after password change: %v", err)
|
||||
}
|
||||
|
||||
if user.SessionVersion == oldSessionVersion {
|
||||
t.Errorf("Expected session version to change after password update")
|
||||
}
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, oldToken)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected old token to be rejected after session version change, got status %d", statusCode)
|
||||
}
|
||||
|
||||
tokenWithOldVersion := generateTokenWithSessionVersion(t, user, &cfg.JWT, oldSessionVersion)
|
||||
statusCode = ctx.makeRequestWithToken(t, tokenWithOldVersion)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected token with old session version to be rejected, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenRefreshWithOldSessionVersion(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_refresh_with_old_session_version", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "refreshold", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
oldRefreshToken := authClient.RefreshToken
|
||||
|
||||
authClient.UpdatePassword(t, "Password123!", "NewPassword777!")
|
||||
|
||||
oldClient := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: authClient.Token,
|
||||
RefreshToken: oldRefreshToken,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
|
||||
_, statusCode := oldClient.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refresh with old refresh token to fail after password change, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_MultiDeviceSession(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("multi_device_session", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "multidev", "Password123!")
|
||||
|
||||
deviceA := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
tokenA := deviceA.Token
|
||||
|
||||
deviceB := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
tokenB := deviceB.Token
|
||||
|
||||
profileA := deviceA.GetProfile(t)
|
||||
if profileA.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected device A to access profile")
|
||||
}
|
||||
|
||||
profileB := deviceB.GetProfile(t)
|
||||
if profileB.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected device B to access profile")
|
||||
}
|
||||
|
||||
deviceA.Logout(t)
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, tokenA)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Logout may not invalidate tokens immediately (acceptable)")
|
||||
}
|
||||
|
||||
profileBAfter := deviceB.GetProfile(t)
|
||||
if profileBAfter.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected device B to still work after device A logout")
|
||||
}
|
||||
|
||||
deviceB.RevokeAllTokens(t)
|
||||
|
||||
_, statusCode = deviceB.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected device B refresh token to be revoked after revoke-all, got status %d", statusCode)
|
||||
}
|
||||
|
||||
statusCode = ctx.makeRequestWithToken(t, tokenB)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Access token may still work after refresh token revocation (acceptable)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RevokeAllInvalidatesAllDevices(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("revoke_all_invalidates_all_devices", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "revokeall", "Password123!")
|
||||
|
||||
device1 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
refreshToken1 := device1.RefreshToken
|
||||
|
||||
device2 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
refreshToken2 := device2.RefreshToken
|
||||
|
||||
device3 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
refreshToken3 := device3.RefreshToken
|
||||
|
||||
device1.RevokeAllTokens(t)
|
||||
|
||||
oldDevice1 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: device1.Token,
|
||||
RefreshToken: refreshToken1,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode := oldDevice1.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected device1 refresh token to be revoked, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldDevice2 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: device2.Token,
|
||||
RefreshToken: refreshToken2,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldDevice2.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected device2 refresh token to be revoked, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldDevice3 := &AuthenticatedClient{
|
||||
AuthenticatedClient: &testutils.AuthenticatedClient{
|
||||
Client: ctx.client,
|
||||
Token: device3.Token,
|
||||
RefreshToken: refreshToken3,
|
||||
BaseURL: ctx.baseURL,
|
||||
},
|
||||
}
|
||||
_, statusCode = oldDevice3.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected device3 refresh token to be revoked, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenTiming(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_timing", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "timing", "Password123!")
|
||||
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-for-testing-purposes-only",
|
||||
Expiration: 24,
|
||||
RefreshExpiration: 168,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
}
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
t.Run("token_just_before_expiry", func(t *testing.T) {
|
||||
token := generateTokenWithExpiration(t, user, &cfg.JWT, 1*time.Minute)
|
||||
statusCode := ctx.makeRequestWithToken(t, token)
|
||||
if statusCode != http.StatusOK {
|
||||
t.Errorf("Expected token just before expiry to work, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_just_after_expiry", func(t *testing.T) {
|
||||
token := generateTokenWithExpiration(t, user, &cfg.JWT, -1*time.Minute)
|
||||
statusCode := ctx.makeRequestWithToken(t, token)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected expired token to be rejected, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_expiration_edge_case", func(t *testing.T) {
|
||||
token := generateTokenWithExpiration(t, user, &cfg.JWT, 0)
|
||||
statusCode := ctx.makeRequestWithToken(t, token)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Token with zero expiration may be accepted (clock skew tolerance)")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenReplayAttack(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_replay_attack", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "replay", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
token := authClient.Token
|
||||
|
||||
t.Run("same_token_multiple_times", func(t *testing.T) {
|
||||
for i := 0; i < 5; i++ {
|
||||
statusCode := ctx.makeRequestWithToken(t, token)
|
||||
if statusCode != http.StatusOK {
|
||||
t.Errorf("Expected token to work multiple times (replay %d), got status %d", i+1, statusCode)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_reuse_after_revocation", func(t *testing.T) {
|
||||
authClient.RevokeAllTokens(t)
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, token)
|
||||
if statusCode == http.StatusOK {
|
||||
t.Log("Access token may still work after refresh token revocation (acceptable)")
|
||||
}
|
||||
|
||||
_, statusCode = authClient.RefreshAccessToken(t)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refresh token to be rejected after revocation, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_reuse_after_user_deletion", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "deleteuser", "Password123!")
|
||||
deleteClient := ctx.loginUser(t, testUser.Username, testUser.Password)
|
||||
deleteToken := deleteClient.Token
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
deleteClient.RequestAccountDeletion(t)
|
||||
deletionToken := ctx.server.EmailSender.DeletionToken()
|
||||
if deletionToken == "" {
|
||||
t.Fatalf("Expected deletion token")
|
||||
}
|
||||
|
||||
deleteClient.ConfirmAccountDeletion(t, deletionToken, false)
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, deleteToken)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected token to be rejected after user deletion, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenScope(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("token_scope", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "scope", "Password123!")
|
||||
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-for-testing-purposes-only",
|
||||
Expiration: 24,
|
||||
RefreshExpiration: 168,
|
||||
Issuer: "goyco",
|
||||
Audience: "goyco-users",
|
||||
},
|
||||
}
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
t.Run("access_token_cannot_be_used_as_refresh", func(t *testing.T) {
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
accessToken := authClient.Token
|
||||
|
||||
refreshData := map[string]string{
|
||||
"refresh_token": accessToken,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(refreshData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal refresh data: %v", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("POST", ctx.baseURL+"/api/auth/refresh", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create refresh request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(request)
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make refresh request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Errorf("Expected access token to be rejected as refresh token, got status 200")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_token_cannot_access_protected_endpoints", func(t *testing.T) {
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
refreshTokenString := authClient.RefreshToken
|
||||
|
||||
statusCode := ctx.makeRequestWithToken(t, refreshTokenString)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected refresh token string to be rejected for protected endpoints, got status %d", statusCode)
|
||||
}
|
||||
|
||||
invalidTypeToken := generateTokenWithType(t, user, &cfg.JWT, "invalid-type")
|
||||
statusCode = ctx.makeRequestWithToken(t, invalidTypeToken)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected invalid token type to be rejected, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token_type_validation", func(t *testing.T) {
|
||||
emptyTypeToken := generateTokenWithType(t, user, &cfg.JWT, "")
|
||||
statusCode := ctx.makeRequestWithToken(t, emptyTypeToken)
|
||||
if statusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected empty token type to be rejected, got status %d", statusCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentLoginPrevention(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_login_prevention", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "concurrent", "Password123!")
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
initialVersion := user.SessionVersion
|
||||
|
||||
login1 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
login2 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
login3 := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
user, err = ctx.server.UserRepo.GetByID(createdUser.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user after logins: %v", err)
|
||||
}
|
||||
|
||||
if user.SessionVersion != initialVersion {
|
||||
t.Log("Session version may increment on login (acceptable behavior)")
|
||||
}
|
||||
|
||||
profile1 := login1.GetProfile(t)
|
||||
if profile1.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected login1 to work")
|
||||
}
|
||||
|
||||
profile2 := login2.GetProfile(t)
|
||||
if profile2.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected login2 to work")
|
||||
}
|
||||
|
||||
profile3 := login3.GetProfile(t)
|
||||
if profile3.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected login3 to work")
|
||||
}
|
||||
|
||||
if login1.Token == login2.Token || login1.Token == login3.Token || login2.Token == login3.Token {
|
||||
t.Errorf("Expected concurrent logins to generate different tokens")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_SecurityWorkflows(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("security_workflows", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "testuser", "StrongPass123!")
|
||||
_ = ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
t.Run("unauthorized_access_attempts", func(t *testing.T) {
|
||||
request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected 401 for unauthorized access, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid_token_access", func(t *testing.T) {
|
||||
request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").
|
||||
WithAuth("invalid-token-12345").
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected 401 for invalid token, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rate_limiting", func(t *testing.T) {
|
||||
rateLimitCtx := setupTestContextWithAuthRateLimit(t, 5)
|
||||
rateLimitUser := rateLimitCtx.createUserWithCleanup(t, "ratelimituser", "StrongPass123!")
|
||||
_ = rateLimitCtx.loginUser(t, rateLimitUser.Username, rateLimitUser.Password)
|
||||
|
||||
testIP := testutils.GenerateTestIP()
|
||||
rateLimited := false
|
||||
for range 10 {
|
||||
statusCode := rateLimitCtx.loginExpectStatusWithIP(t, rateLimitUser.Username, "WrongPass123!", http.StatusUnauthorized, testIP)
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
rateLimited = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !rateLimited {
|
||||
t.Errorf("Expected rate limiting to occur after multiple failed login attempts")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SearchSanitization(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("search_sanitization", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
_ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "This post contains searchable content")
|
||||
|
||||
benignSearch := authClient.SearchPosts(t, "searchable")
|
||||
if !benignSearch.Success {
|
||||
t.Errorf("Expected benign search to succeed, got failure: %s", benignSearch.Message)
|
||||
}
|
||||
if len(benignSearch.Data.Posts) == 0 {
|
||||
t.Errorf("Expected to find post with benign search query")
|
||||
}
|
||||
|
||||
maliciousQuery := "searchable'; DROP TABLE users; --"
|
||||
request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/posts/search?q="+url.QueryEscape(maliciousQuery)).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create malicious search request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make malicious search request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for malicious search query, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SecurityHeaders(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
expectedHeaders := map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
}
|
||||
|
||||
type endpointTest struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
auth bool
|
||||
body []byte
|
||||
}
|
||||
|
||||
endpoints := []endpointTest{
|
||||
{name: "health_endpoint", method: "GET", path: "/health", auth: false},
|
||||
{name: "metrics_endpoint", method: "GET", path: "/metrics", auth: false},
|
||||
{name: "api_registration", method: "POST", path: "/api/auth/register", auth: false, body: []byte(`{"username":"testuser","email":"test@example.com","password":"StrongPass123!"}`)},
|
||||
{name: "api_posts", method: "GET", path: "/api/posts", auth: true},
|
||||
{name: "api_auth_me", method: "GET", path: "/api/auth/me", auth: true},
|
||||
}
|
||||
|
||||
t.Run("security_headers_on_all_endpoints", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "headertest", "StrongPass123!")
|
||||
var authToken string
|
||||
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err == nil {
|
||||
authToken = authClient.Token
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint.name, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
if endpoint.body != nil {
|
||||
req, err = http.NewRequest(endpoint.method, ctx.baseURL+endpoint.path, bytes.NewReader(endpoint.body))
|
||||
} else {
|
||||
req, err = http.NewRequest(endpoint.method, ctx.baseURL+endpoint.path, nil)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
if endpoint.auth && authToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
}
|
||||
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for headerName, expectedValue := range expectedHeaders {
|
||||
actualValue := resp.Header.Get(headerName)
|
||||
if actualValue != expectedValue {
|
||||
t.Errorf("Endpoint %s: Expected %s header to be '%s', got '%s'", endpoint.path, headerName, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
|
||||
csp := resp.Header.Get("Content-Security-Policy")
|
||||
if csp == "" {
|
||||
t.Errorf("Endpoint %s: Content-Security-Policy header should be present", endpoint.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SQLInjectionAcrossEndpoints(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
sqlPayloads := testutils.SQLInjectionPayloads
|
||||
|
||||
t.Run("sql_injection_in_post_fields", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sqltest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping sql injection in post fields test: %v", err)
|
||||
}
|
||||
|
||||
for i, payload := range sqlPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
postData := map[string]string{
|
||||
"title": payload,
|
||||
"url": fmt.Sprintf("https://example.com/test%d", i),
|
||||
"content": "Test content",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in title caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
postData2 := map[string]string{
|
||||
"title": fmt.Sprintf("Test Post %d", i),
|
||||
"url": fmt.Sprintf("https://example.com/test2-%d", i),
|
||||
"content": payload,
|
||||
}
|
||||
|
||||
req2, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData2).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in content caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql_injection_in_registration_fields", func(t *testing.T) {
|
||||
for i, payload := range sqlPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
regData := map[string]string{
|
||||
"username": payload,
|
||||
"email": uniqueEmail(t, fmt.Sprintf("test%d", i)),
|
||||
"password": "StrongPass123!",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithJSONBody(regData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in username caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
regData2 := map[string]string{
|
||||
"username": uniqueUsername(t, fmt.Sprintf("user%d", i)),
|
||||
"email": fmt.Sprintf("test%s@example.com", payload),
|
||||
"password": "StrongPass123!",
|
||||
}
|
||||
|
||||
req2, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithJSONBody(regData2).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in email caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql_injection_in_url_fields", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sqltest2", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping sql injection in url fields test: %v", err)
|
||||
}
|
||||
|
||||
for i, payload := range sqlPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
postData := map[string]string{
|
||||
"title": fmt.Sprintf("Test Post %d", i),
|
||||
"url": fmt.Sprintf("https://example.com/test%s", payload),
|
||||
"content": "Test content",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in URL caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql_injection_in_query_parameters", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sqltest3", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping sql injection in query parameters test: %v", err)
|
||||
}
|
||||
|
||||
_ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "Content")
|
||||
|
||||
for i, payload := range sqlPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
searchURL := ctx.baseURL + "/api/posts/search?q=" + url.QueryEscape(payload)
|
||||
|
||||
req, err := testutils.NewRequestBuilder("GET", searchURL).
|
||||
WithAuth(authClient.Token).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in search query caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusOK {
|
||||
t.Logf("SQL injection in search query returned status %d (acceptable if sanitized). Payload: %s", resp.StatusCode, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_XSSPrevention(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
xssPayloads := testutils.XSSPayloads
|
||||
|
||||
t.Run("xss_in_post_fields", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "xsstest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
for idx, payload := range xssPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", idx), func(t *testing.T) {
|
||||
postData := map[string]string{
|
||||
"title": payload,
|
||||
"url": fmt.Sprintf("https://example.com/xss-test-%d", idx),
|
||||
"content": "Test content",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("XSS payload in title caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
reader, cleanup, err := getResponseReader(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get response reader: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var postResp PostResponse
|
||||
if err := json.NewDecoder(reader).Decode(&postResp); err == nil {
|
||||
if strings.Contains(postResp.Data.Title, "<script") {
|
||||
t.Errorf("XSS payload not sanitized in title response. Payload: %s, Response: %s", payload, postResp.Data.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postData2 := map[string]string{
|
||||
"title": fmt.Sprintf("Test Post %d", idx),
|
||||
"url": fmt.Sprintf("https://example.com/xss-test2-%d", idx),
|
||||
"content": payload,
|
||||
}
|
||||
|
||||
req2, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData2).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("XSS payload in content caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp2.StatusCode == http.StatusCreated {
|
||||
reader, cleanup, err := getResponseReader(resp2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get response reader: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var postResp PostResponse
|
||||
if err := json.NewDecoder(reader).Decode(&postResp); err == nil {
|
||||
if strings.Contains(postResp.Data.Content, "<script") || strings.Contains(postResp.Data.Content, "javascript:") {
|
||||
t.Errorf("XSS payload not sanitized in content response. Payload: %s", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("xss_in_username_fields", func(t *testing.T) {
|
||||
for i, payload := range xssPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
regData := map[string]string{
|
||||
"username": payload,
|
||||
"email": uniqueEmail(t, fmt.Sprintf("test%d", i)),
|
||||
"password": "StrongPass123!",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithJSONBody(regData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("XSS payload in username caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("xss_in_search_queries", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "xsstest2", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
_ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "Content")
|
||||
|
||||
for i, payload := range xssPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
searchURL := ctx.baseURL + "/api/posts/search?q=" + url.QueryEscape(payload)
|
||||
|
||||
req, err := testutils.NewRequestBuilder("GET", searchURL).
|
||||
WithAuth(authClient.Token).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("XSS payload in search query caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
reader, cleanup, err := getResponseReader(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get response reader: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var searchResp PostsListResponse
|
||||
if err := json.NewDecoder(reader).Decode(&searchResp); err != nil {
|
||||
t.Fatalf("Failed to decode search response: %v", err)
|
||||
}
|
||||
|
||||
for _, post := range searchResp.Data.Posts {
|
||||
if strings.Contains(post.Title, "<script") || strings.Contains(post.Title, "javascript:") {
|
||||
t.Errorf("XSS payload not sanitized in post title. Payload: %s", payload)
|
||||
}
|
||||
if strings.Contains(post.Content, "<script") || strings.Contains(post.Content, "javascript:") {
|
||||
t.Errorf("XSS payload not sanitized in post content. Payload: %s", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_InformationDisclosure(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("information_disclosure", func(t *testing.T) {
|
||||
t.Run("error_messages_dont_reveal_sensitive_info", func(t *testing.T) {
|
||||
request, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/login").
|
||||
WithJSONBody(map[string]string{
|
||||
"username": "nonexistent",
|
||||
"password": "wrongpassword",
|
||||
}).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
if strings.Contains(strings.ToLower(bodyStr), "database") {
|
||||
t.Errorf("Error message should not reveal database information")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(bodyStr), "sql") {
|
||||
t.Errorf("Error message should not reveal SQL information")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(bodyStr), "stack") {
|
||||
t.Errorf("Error message should not reveal stack trace")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid_endpoints_dont_reveal_structure", func(t *testing.T) {
|
||||
request, err := http.NewRequest("GET", ctx.baseURL+"/api/nonexistent/endpoint", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
if strings.Contains(strings.ToLower(bodyStr), "route") && resp.StatusCode == http.StatusNotFound {
|
||||
t.Logf("404 response may contain route information, which is acceptable")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
func TestE2E_SecurityHeadersEnhanced(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
expectedHeaders := map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
}
|
||||
|
||||
t.Run("security_headers_values", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "headertest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
auth bool
|
||||
}{
|
||||
{"health", "GET", "/health", false},
|
||||
{"metrics", "GET", "/metrics", false},
|
||||
{"api_posts", "GET", "/api/posts", true},
|
||||
{"api_auth_me", "GET", "/api/auth/me", true},
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(endpoint.method, ctx.baseURL+endpoint.path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
if endpoint.auth && authClient != nil {
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for headerName, expectedValue := range expectedHeaders {
|
||||
actualValue := resp.Header.Get(headerName)
|
||||
if actualValue != expectedValue {
|
||||
t.Errorf("Endpoint %s: Expected %s header to be '%s', got '%s'", endpoint.path, headerName, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
|
||||
csp := resp.Header.Get("Content-Security-Policy")
|
||||
if csp == "" {
|
||||
t.Errorf("Endpoint %s: Content-Security-Policy header should be present", endpoint.path)
|
||||
}
|
||||
|
||||
if strings.Contains(csp, "unsafe-inline") && !strings.Contains(csp, "'nonce-") {
|
||||
t.Errorf("Endpoint %s: CSP contains unsafe-inline without nonce", endpoint.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hsts_header", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
hsts := resp.Header.Get("Strict-Transport-Security")
|
||||
if hsts == "" {
|
||||
t.Error("HSTS header should be present for HTTPS requests")
|
||||
}
|
||||
|
||||
if !strings.Contains(hsts, "max-age=") {
|
||||
t.Errorf("HSTS header should contain max-age, got: %s", hsts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ParameterizedQueries(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("sql_injection_prevention", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sqltest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
for i, payload := range testutils.SQLInjectionPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
postData := map[string]string{
|
||||
"title": payload,
|
||||
"url": fmt.Sprintf("https://example.com/test%d", i),
|
||||
"content": "Test content",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in title caused server error (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("Expected post creation to succeed (parameterized queries prevent SQL injection), got status: %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search_sanitization", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "searchtest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
_ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "Content")
|
||||
|
||||
for i, payload := range testutils.SQLInjectionPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
searchURL := ctx.baseURL + "/api/posts/search?q=" + url.QueryEscape(payload)
|
||||
|
||||
req, err := testutils.NewRequestBuilder("GET", searchURL).
|
||||
WithAuth(authClient.Token).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in search query caused server error (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenHashing(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("verification_token_hashed", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "hashtest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
authClient.RegisterUser(t, "newuser", "newuser@example.com", "Password123!")
|
||||
|
||||
verificationToken := ctx.server.EmailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
t.Fatal("Expected verification token to be generated")
|
||||
}
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByUsername("newuser")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
if user.EmailVerificationToken == verificationToken {
|
||||
t.Error("Verification token should be hashed in database")
|
||||
}
|
||||
|
||||
if len(user.EmailVerificationToken) < 32 {
|
||||
t.Errorf("Hashed token should be at least 32 characters, got %d", len(user.EmailVerificationToken))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("password_reset_token_hashed", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "resettest", "StrongPass123!")
|
||||
ctx.server.EmailSender.Reset()
|
||||
|
||||
testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, testUser.Email, testutils.GenerateTestIP())
|
||||
|
||||
resetToken := ctx.server.EmailSender.PasswordResetToken()
|
||||
if resetToken == "" {
|
||||
t.Skip("Rate limited, skipping token hashing test")
|
||||
return
|
||||
}
|
||||
|
||||
hash := sha256.Sum256([]byte(resetToken))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
deletionRepo := repositories.NewAccountDeletionRepository(ctx.server.DB)
|
||||
_, err := deletionRepo.GetByTokenHash(tokenHash)
|
||||
if err == nil {
|
||||
t.Log("Password reset token appears to be stored hashed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SecurityHeaderCombinations(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("all_security_headers_present", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
requiredHeaders := []string{
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"X-XSS-Protection",
|
||||
"Referrer-Policy",
|
||||
"Content-Security-Policy",
|
||||
}
|
||||
|
||||
for _, header := range requiredHeaders {
|
||||
if resp.Header.Get(header) == "" {
|
||||
t.Errorf("Required security header missing: %s", header)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_StaticFileServing(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("static_css_file_served", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/static/css/main.css", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/css") && !strings.Contains(contentType, "application/octet-stream") {
|
||||
t.Logf("Unexpected Content-Type for CSS file: %s", contentType)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
t.Error("Static CSS file is empty")
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusNotFound {
|
||||
t.Log("Static CSS file not found (may not exist in test environment)")
|
||||
} else {
|
||||
t.Errorf("Expected status 200 or 404, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static_file_not_found", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/static/nonexistent/file.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404 for nonexistent file, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static_directory_listing_disabled", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/static/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusForbidden {
|
||||
t.Logf("Directory listing status: %d (acceptable)", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static_favicon_served", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/static/favicon.ico", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "image") && !strings.Contains(contentType, "application/octet-stream") {
|
||||
t.Logf("Unexpected Content-Type for favicon: %s", contentType)
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusNotFound {
|
||||
t.Log("Favicon not found (may not exist in test environment)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("static_path_traversal_prevented", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/static/../common.go", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("Expected 404 or 403 for path traversal attempt, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestE2E_UserDirectory(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("user_directory", func(t *testing.T) {
|
||||
users := ctx.createMultipleUsersWithCleanup(t, 3, "user", "StrongPass123!")
|
||||
|
||||
authClient := ctx.loginUser(t, users[0].Username, users[0].Password)
|
||||
|
||||
usersResp := authClient.GetUsers(t)
|
||||
if len(usersResp.Data.Users) < 3 {
|
||||
t.Errorf("Expected at least 3 users, got %d", len(usersResp.Data.Users))
|
||||
}
|
||||
|
||||
for _, user := range usersResp.Data.Users {
|
||||
if user.Username == "" {
|
||||
t.Errorf("Expected username to be present, got empty string")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ProfileManagement(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("profile_management", func(t *testing.T) {
|
||||
createdUser, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
profile := authClient.GetProfile(t)
|
||||
assertUserResponse(t, profile, createdUser)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ProfileAccessAuthorization(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("profile_access_authorization", func(t *testing.T) {
|
||||
createdUsers := ctx.createMultipleUsersWithCleanup(t, 2, "profileuser", "StrongPass123!")
|
||||
user1 := createdUsers[0]
|
||||
user2 := createdUsers[1]
|
||||
|
||||
authClient1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
authClient2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
|
||||
user2CurrentUsername := user2.Username
|
||||
|
||||
t.Run("users_only_see_own_profile_via_me_endpoint", func(t *testing.T) {
|
||||
profile1 := authClient1.GetProfile(t)
|
||||
if profile1.Data.ID != user1.ID {
|
||||
t.Errorf("User1's /api/auth/me shows wrong ID: expected %d, got %d", user1.ID, profile1.Data.ID)
|
||||
}
|
||||
if profile1.Data.Username != user1.Username {
|
||||
t.Errorf("User1's /api/auth/me shows wrong username: expected '%s', got '%s'", user1.Username, profile1.Data.Username)
|
||||
}
|
||||
if profile1.Data.Email != user1.Email {
|
||||
t.Errorf("User1's /api/auth/me shows wrong email: expected '%s', got '%s'", user1.Email, profile1.Data.Email)
|
||||
}
|
||||
|
||||
profile2 := authClient2.GetProfile(t)
|
||||
if profile2.Data.ID != user2.ID {
|
||||
t.Errorf("User2's /api/auth/me shows wrong ID: expected %d, got %d", user2.ID, profile2.Data.ID)
|
||||
}
|
||||
if profile2.Data.Username != user2.Username {
|
||||
t.Errorf("User2's /api/auth/me shows wrong username: expected '%s', got '%s'", user2.Username, profile2.Data.Username)
|
||||
}
|
||||
if profile2.Data.Email != user2.Email {
|
||||
t.Errorf("User2's /api/auth/me shows wrong email: expected '%s', got '%s'", user2.Email, profile2.Data.Email)
|
||||
}
|
||||
|
||||
if profile1.Data.ID == profile2.Data.ID {
|
||||
t.Errorf("User1 and User2 profiles should have different IDs via /api/auth/me, but both show %d", profile1.Data.ID)
|
||||
}
|
||||
if profile1.Data.Username == profile2.Data.Username {
|
||||
t.Errorf("User1 and User2 profiles should have different usernames via /api/auth/me, but both show '%s'", profile1.Data.Username)
|
||||
}
|
||||
if profile1.Data.Email == profile2.Data.Email {
|
||||
t.Errorf("User1 and User2 profiles should have different emails via /api/auth/me, but both show '%s'", profile1.Data.Email)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("users_cannot_modify_other_users_email", func(t *testing.T) {
|
||||
originalProfile1 := authClient1.GetProfile(t)
|
||||
originalEmail1 := originalProfile1.Data.Email
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
statusCode := authClient2.UpdateEmailExpectStatus(t, uniqueEmail(t, "newemail2"))
|
||||
if statusCode != http.StatusOK {
|
||||
t.Errorf("Expected User2 to be able to update their own email with status 200, got %d", statusCode)
|
||||
}
|
||||
|
||||
verificationToken := ctx.server.EmailSender.VerificationToken()
|
||||
if verificationToken != "" {
|
||||
ctx.confirmEmail(t, verificationToken)
|
||||
}
|
||||
|
||||
updatedProfile1 := authClient1.GetProfile(t)
|
||||
if updatedProfile1.Data.Email != originalEmail1 {
|
||||
t.Errorf("User2 updating their own email should not affect User1's email. Expected '%s', got '%s'", originalEmail1, updatedProfile1.Data.Email)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("users_cannot_modify_other_users_username", func(t *testing.T) {
|
||||
originalProfile1 := authClient1.GetProfile(t)
|
||||
originalUsername1 := originalProfile1.Data.Username
|
||||
|
||||
user2CurrentUsername = uniqueUsername(t, "newusername2")
|
||||
authClient2.UpdateUsername(t, user2CurrentUsername)
|
||||
|
||||
updatedProfile1 := authClient1.GetProfile(t)
|
||||
if updatedProfile1.Data.Username != originalUsername1 {
|
||||
t.Errorf("User2 updating their own username should not affect User1's username. Expected '%s', got '%s'", originalUsername1, updatedProfile1.Data.Username)
|
||||
}
|
||||
|
||||
updatedProfile2 := authClient2.GetProfile(t)
|
||||
if updatedProfile2.Data.Username == originalUsername1 {
|
||||
t.Errorf("Expected User2's username to be updated, but it's still '%s'", originalUsername1)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("users_cannot_modify_other_users_password", func(t *testing.T) {
|
||||
baselineAuthClient1 := ctx.loginUser(t, user1.Username, "StrongPass123!")
|
||||
if baselineAuthClient1.Token == "" {
|
||||
t.Fatalf("User1 should be able to login with original password before User2's update")
|
||||
}
|
||||
|
||||
authClient2.UpdatePassword(t, "StrongPass123!", "NewPass456!")
|
||||
|
||||
newAuthClient1 := ctx.loginUser(t, user1.Username, "StrongPass123!")
|
||||
if newAuthClient1.Token == "" {
|
||||
t.Errorf("User1 should still be able to login with original password after User2 updates their own password")
|
||||
}
|
||||
|
||||
profile1After := newAuthClient1.GetProfile(t)
|
||||
if profile1After.Data.Username != user1.Username {
|
||||
t.Errorf("User1's username should remain unchanged after User2's password update. Expected '%s', got '%s'", user1.Username, profile1After.Data.Username)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user1_updates_dont_affect_user2", func(t *testing.T) {
|
||||
authClient2 = ctx.loginUser(t, user2CurrentUsername, "NewPass456!")
|
||||
originalProfile2 := authClient2.GetProfile(t)
|
||||
originalUsername2 := originalProfile2.Data.Username
|
||||
|
||||
authClient1.UpdateUsername(t, uniqueUsername(t, "newusername1"))
|
||||
|
||||
updatedProfile2 := authClient2.GetProfile(t)
|
||||
if updatedProfile2.Data.Username != originalUsername2 {
|
||||
t.Errorf("User1 updating their own username should not affect User2's username. Expected '%s', got '%s'", originalUsername2, updatedProfile2.Data.Username)
|
||||
}
|
||||
|
||||
updatedProfile1 := authClient1.GetProfile(t)
|
||||
if updatedProfile1.Data.Username == originalUsername2 {
|
||||
t.Errorf("Expected User1's username to be updated, but it's still '%s'", originalUsername2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("profiles_remain_isolated_after_updates", func(t *testing.T) {
|
||||
authClient2 = ctx.loginUser(t, user2CurrentUsername, "NewPass456!")
|
||||
finalProfile1 := authClient1.GetProfile(t)
|
||||
finalProfile2 := authClient2.GetProfile(t)
|
||||
|
||||
if finalProfile1.Data.ID == finalProfile2.Data.ID {
|
||||
t.Errorf("After all updates, User1 and User2 should still have different IDs, but both show %d", finalProfile1.Data.ID)
|
||||
}
|
||||
if finalProfile1.Data.Username == finalProfile2.Data.Username {
|
||||
t.Errorf("After all updates, User1 and User2 should still have different usernames, but both show '%s'", finalProfile1.Data.Username)
|
||||
}
|
||||
if finalProfile1.Data.Email == finalProfile2.Data.Email {
|
||||
t.Errorf("After all updates, User1 and User2 should still have different emails, but both show '%s'", finalProfile1.Data.Email)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_VersionEndpoint(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("version_in_api_info", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for /api endpoint, got %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var apiInfo map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiInfo); err != nil {
|
||||
t.Fatalf("Failed to decode API info response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := apiInfo["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("API info data is not a map")
|
||||
}
|
||||
|
||||
version, ok := data["version"].(string)
|
||||
if !ok {
|
||||
t.Error("Version field missing or not a string in API info")
|
||||
return
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
t.Error("Version is empty")
|
||||
}
|
||||
|
||||
versionPattern := regexp.MustCompile(`^\d+\.\d+\.\d+`)
|
||||
if !versionPattern.MatchString(version) {
|
||||
t.Errorf("Version format invalid, expected semantic version (x.y.z), got: %s", version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version_in_health_endpoint", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for /health endpoint, got %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var healthInfo map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&healthInfo); err != nil {
|
||||
t.Fatalf("Failed to decode health response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := healthInfo["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Health data is not a map")
|
||||
}
|
||||
|
||||
version, ok := data["version"].(string)
|
||||
if !ok {
|
||||
t.Error("Version field missing or not a string in health info")
|
||||
return
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
t.Error("Version is empty")
|
||||
}
|
||||
|
||||
versionPattern := regexp.MustCompile(`^\d+\.\d+\.\d+`)
|
||||
if !versionPattern.MatchString(version) {
|
||||
t.Errorf("Version format invalid, expected semantic version (x.y.z), got: %s", version)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version_consistency", func(t *testing.T) {
|
||||
apiReq, err := http.NewRequest("GET", ctx.baseURL+"/api", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(apiReq)
|
||||
|
||||
apiResp, err := ctx.client.Do(apiReq)
|
||||
if err != nil {
|
||||
t.Fatalf("API request failed: %v", err)
|
||||
}
|
||||
defer apiResp.Body.Close()
|
||||
|
||||
healthReq, err := http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create health request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(healthReq)
|
||||
|
||||
healthResp, err := ctx.client.Do(healthReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Health request failed: %v", err)
|
||||
}
|
||||
defer healthResp.Body.Close()
|
||||
|
||||
if apiResp.StatusCode != http.StatusOK || healthResp.StatusCode != http.StatusOK {
|
||||
t.Skip("One or both endpoints unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var apiInfo map[string]interface{}
|
||||
if err := json.NewDecoder(apiResp.Body).Decode(&apiInfo); err != nil {
|
||||
t.Fatalf("Failed to decode API info: %v", err)
|
||||
}
|
||||
|
||||
var healthInfo map[string]interface{}
|
||||
if err := json.NewDecoder(healthResp.Body).Decode(&healthInfo); err != nil {
|
||||
t.Fatalf("Failed to decode health info: %v", err)
|
||||
}
|
||||
|
||||
apiData, _ := apiInfo["data"].(map[string]interface{})
|
||||
healthData, _ := healthInfo["data"].(map[string]interface{})
|
||||
|
||||
apiVersion, apiOk := apiData["version"].(string)
|
||||
healthVersion, healthOk := healthData["version"].(string)
|
||||
|
||||
if apiOk && healthOk && apiVersion != healthVersion {
|
||||
t.Errorf("Version mismatch: /api has %s, /health has %s", apiVersion, healthVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version_format_validation", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/api", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skip("API endpoint unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var apiInfo map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiInfo); err != nil {
|
||||
t.Fatalf("Failed to decode API info: %v", err)
|
||||
}
|
||||
|
||||
data, ok := apiInfo["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
version, ok := data["version"].(string)
|
||||
if !ok || version == "" {
|
||||
return
|
||||
}
|
||||
|
||||
semverPattern := regexp.MustCompile(`^\d+\.\d+\.\d+(-[a-zA-Z0-9-]+)?(\+[a-zA-Z0-9-]+)?$`)
|
||||
if !semverPattern.MatchString(version) {
|
||||
t.Logf("Version '%s' does not strictly follow semantic versioning (acceptable)", version)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestE2E_VoteManagement(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("vote_operations", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
createdPost := authClient.CreatePost(t, "Vote Test Post", "https://example.com/vote", "Content for voting")
|
||||
|
||||
var voteResp *VoteResponse
|
||||
statusCode := retryOnRateLimit(t, 3, func() int {
|
||||
resp, code := authClient.VoteOnPostRaw(t, createdPost.ID, "up")
|
||||
if code == http.StatusOK {
|
||||
voteResp = resp
|
||||
}
|
||||
return code
|
||||
})
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
t.Skip("Skipping vote operations test: rate limited after retries")
|
||||
return
|
||||
}
|
||||
if statusCode != http.StatusOK {
|
||||
t.Fatalf("Vote failed with status %d", statusCode)
|
||||
}
|
||||
assertVoteResponse(t, voteResp, "up")
|
||||
|
||||
userVote := authClient.GetUserVote(t, createdPost.ID)
|
||||
if !userVote.Success {
|
||||
t.Errorf("Expected to get user vote, got failure: %s", userVote.Message)
|
||||
}
|
||||
userVoteData := assertVoteData(t, userVote)
|
||||
if hasVote, ok := userVoteData["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected has_vote true after casting vote, got %#v", userVoteData["has_vote"])
|
||||
}
|
||||
|
||||
postVotes := authClient.GetPostVotes(t, createdPost.ID)
|
||||
if !postVotes.Success {
|
||||
t.Errorf("Expected to get post votes, got failure: %s", postVotes.Message)
|
||||
}
|
||||
postVotesData := assertVoteData(t, postVotes)
|
||||
if count, ok := postVotesData["count"].(float64); !ok || count < 1 {
|
||||
t.Errorf("Expected post votes count to be >= 1, got %#v", postVotesData["count"])
|
||||
}
|
||||
|
||||
authClient.RemoveVote(t, createdPost.ID)
|
||||
|
||||
removedVote := authClient.GetUserVote(t, createdPost.ID)
|
||||
if !removedVote.Success {
|
||||
t.Errorf("Expected to get vote removal state, got failure: %s", removedVote.Message)
|
||||
}
|
||||
removedVoteData := assertVoteData(t, removedVote)
|
||||
if hasVote, ok := removedVoteData["has_vote"].(bool); ok && hasVote {
|
||||
t.Errorf("Expected has_vote false after removal, got true")
|
||||
}
|
||||
if voteVal, present := removedVoteData["vote"]; present && voteVal != nil {
|
||||
t.Errorf("Expected vote data to be nil after removal, got %#v", voteVal)
|
||||
}
|
||||
|
||||
postVotesAfter := authClient.GetPostVotes(t, createdPost.ID)
|
||||
if !postVotesAfter.Success {
|
||||
t.Errorf("Expected to get post votes after removal, got failure: %s", postVotesAfter.Message)
|
||||
}
|
||||
postVotesAfterData := assertVoteData(t, postVotesAfter)
|
||||
if count, ok := postVotesAfterData["count"].(float64); ok && count != 0 {
|
||||
t.Errorf("Expected post votes count to be 0 after removal, got %v", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_VoteAuthorization(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("vote_authorization", func(t *testing.T) {
|
||||
createdUsers := ctx.createMultipleUsersWithCleanup(t, 2, "voteuser", "StrongPass123!")
|
||||
user1 := createdUsers[0]
|
||||
user2 := createdUsers[1]
|
||||
|
||||
authClient1 := ctx.loginUser(t, user1.Username, user1.Password)
|
||||
authClient2 := ctx.loginUser(t, user2.Username, user2.Password)
|
||||
|
||||
createdPost := authClient1.CreatePost(t, "Vote Test Post", "https://example.com/vote", "Content for voting tests")
|
||||
|
||||
t.Run("users_can_only_vote_with_own_token", func(t *testing.T) {
|
||||
voteResp1 := authClient1.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp1.Success {
|
||||
t.Errorf("Expected User1 to be able to vote with their own token, got failure: %s", voteResp1.Message)
|
||||
}
|
||||
|
||||
userVote1 := authClient1.GetUserVote(t, createdPost.ID)
|
||||
if !userVote1.Success {
|
||||
t.Errorf("Expected to get User1's vote, got failure: %s", userVote1.Message)
|
||||
}
|
||||
userVote1Data := assertVoteData(t, userVote1)
|
||||
if hasVote, ok := userVote1Data["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User1 to have a vote after voting, got has_vote=%v", userVote1Data["has_vote"])
|
||||
}
|
||||
|
||||
voteResp2 := authClient2.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp2.Success {
|
||||
t.Errorf("Expected User2 to be able to vote with their own token, got failure: %s", voteResp2.Message)
|
||||
}
|
||||
|
||||
userVote2 := authClient2.GetUserVote(t, createdPost.ID)
|
||||
if !userVote2.Success {
|
||||
t.Errorf("Expected to get User2's vote, got failure: %s", userVote2.Message)
|
||||
}
|
||||
userVote2Data := assertVoteData(t, userVote2)
|
||||
if hasVote, ok := userVote2Data["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User2 to have a vote after voting, got has_vote=%v", userVote2Data["has_vote"])
|
||||
}
|
||||
|
||||
userVote1After := authClient1.GetUserVote(t, createdPost.ID)
|
||||
if !userVote1After.Success {
|
||||
t.Errorf("Expected to still get User1's vote after User2 votes, got failure: %s", userVote1After.Message)
|
||||
}
|
||||
userVote1AfterData := assertVoteData(t, userVote1After)
|
||||
if hasVote, ok := userVote1AfterData["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User1's vote to still exist after User2 votes, got has_vote=%v", userVote1AfterData["has_vote"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vote_counts_reflect_authenticated_votes", func(t *testing.T) {
|
||||
postVotes := authClient1.GetPostVotes(t, createdPost.ID)
|
||||
if !postVotes.Success {
|
||||
t.Errorf("Expected to get post votes, got failure: %s", postVotes.Message)
|
||||
}
|
||||
|
||||
postVotesData := assertVoteData(t, postVotes)
|
||||
|
||||
count, ok := postVotesData["count"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Expected count to be a number, got %T", postVotesData["count"])
|
||||
}
|
||||
if count < 2 {
|
||||
t.Errorf("Expected vote count to be at least 2 (User1 and User2 both voted), got %v", count)
|
||||
}
|
||||
|
||||
votesArray, ok := postVotesData["votes"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("Expected votes to be an array, got %T", postVotesData["votes"])
|
||||
}
|
||||
if len(votesArray) < 2 {
|
||||
t.Errorf("Expected at least 2 votes in the votes array, got %d", len(votesArray))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("users_can_only_modify_own_votes", func(t *testing.T) {
|
||||
authClient1.RemoveVote(t, createdPost.ID)
|
||||
|
||||
userVote1After := authClient1.GetUserVote(t, createdPost.ID)
|
||||
if !userVote1After.Success {
|
||||
t.Errorf("Expected to get vote state after removal, got failure: %s", userVote1After.Message)
|
||||
}
|
||||
userVote1AfterData := assertVoteData(t, userVote1After)
|
||||
if hasVote, ok := userVote1AfterData["has_vote"].(bool); ok && hasVote {
|
||||
t.Errorf("Expected User1's vote to be removed, but has_vote is still true")
|
||||
}
|
||||
|
||||
userVote2After := authClient2.GetUserVote(t, createdPost.ID)
|
||||
if !userVote2After.Success {
|
||||
t.Errorf("Expected to get User2's vote, got failure: %s", userVote2After.Message)
|
||||
}
|
||||
userVote2AfterData := assertVoteData(t, userVote2After)
|
||||
if hasVote, ok := userVote2AfterData["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User2's vote to still exist after User1 removes their vote, got has_vote=%v", userVote2AfterData["has_vote"])
|
||||
}
|
||||
|
||||
postVotesAfter := authClient1.GetPostVotes(t, createdPost.ID)
|
||||
if !postVotesAfter.Success {
|
||||
t.Errorf("Expected to get post votes after removal, got failure: %s", postVotesAfter.Message)
|
||||
}
|
||||
postVotesAfterData := assertVoteData(t, postVotesAfter)
|
||||
countAfter, ok := postVotesAfterData["count"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Expected count to be a number, got %T", postVotesAfterData["count"])
|
||||
}
|
||||
|
||||
if countAfter < 1 {
|
||||
t.Errorf("Expected vote count to be at least 1 after User1 removes vote, got %v", countAfter)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vote_counts_accurate_with_different_types", func(t *testing.T) {
|
||||
voteResp1Down := authClient1.VoteOnPost(t, createdPost.ID, "down")
|
||||
if !voteResp1Down.Success {
|
||||
t.Errorf("Expected User1 to be able to vote down, got failure: %s", voteResp1Down.Message)
|
||||
}
|
||||
|
||||
postVotes := authClient2.GetPostVotes(t, createdPost.ID)
|
||||
if !postVotes.Success {
|
||||
t.Errorf("Expected to get post votes, got failure: %s", postVotes.Message)
|
||||
}
|
||||
|
||||
postVotesData := assertVoteData(t, postVotes)
|
||||
|
||||
count := postVotesData["count"].(float64)
|
||||
if count < 2 {
|
||||
t.Errorf("Expected vote count to be at least 2 (User1 downvote, User2 upvote), got %v", count)
|
||||
}
|
||||
|
||||
userVote1 := authClient1.GetUserVote(t, createdPost.ID)
|
||||
userVote1Data := assertVoteData(t, userVote1)
|
||||
if voteData, exists := userVote1Data["vote"].(map[string]any); exists {
|
||||
if voteType, exists := voteData["type"].(string); exists {
|
||||
if voteType != "down" {
|
||||
t.Errorf("Expected User1's vote type to be 'down', got '%s'", voteType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userVote2 := authClient2.GetUserVote(t, createdPost.ID)
|
||||
userVote2Data := assertVoteData(t, userVote2)
|
||||
if voteData, exists := userVote2Data["vote"].(map[string]any); exists {
|
||||
if voteType, exists := voteData["type"].(string); exists {
|
||||
if voteType != "up" {
|
||||
t.Errorf("Expected User2's vote type to be 'up', got '%s'", voteType)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple_users_vote_independently", func(t *testing.T) {
|
||||
user3 := ctx.createUserWithCleanup(t, "voteuser3", "StrongPass123!")
|
||||
authClient3 := ctx.loginUser(t, user3.Username, user3.Password)
|
||||
|
||||
voteResp3 := authClient3.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp3.Success {
|
||||
t.Errorf("Expected User3 to be able to vote, got failure: %s", voteResp3.Message)
|
||||
}
|
||||
|
||||
userVote1 := authClient1.GetUserVote(t, createdPost.ID)
|
||||
userVote1Data := assertVoteData(t, userVote1)
|
||||
if hasVote, ok := userVote1Data["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User1 to still have a vote")
|
||||
}
|
||||
|
||||
userVote2 := authClient2.GetUserVote(t, createdPost.ID)
|
||||
userVote2Data := assertVoteData(t, userVote2)
|
||||
if hasVote, ok := userVote2Data["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User2 to still have a vote")
|
||||
}
|
||||
|
||||
userVote3 := authClient3.GetUserVote(t, createdPost.ID)
|
||||
userVote3Data := assertVoteData(t, userVote3)
|
||||
if hasVote, ok := userVote3Data["has_vote"].(bool); !ok || !hasVote {
|
||||
t.Errorf("Expected User3 to have a vote after voting")
|
||||
}
|
||||
|
||||
postVotes := authClient3.GetPostVotes(t, createdPost.ID)
|
||||
postVotesData := assertVoteData(t, postVotes)
|
||||
count, ok := postVotesData["count"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Expected count to be a number, got %T", postVotesData["count"])
|
||||
}
|
||||
if count < 3 {
|
||||
t.Errorf("Expected vote count to be at least 3 (three users voted), got %v", count)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func findPostInList(postsResp *testutils.PostsListResponse, postID uint) *testutils.Post {
|
||||
if postsResp == nil || postsResp.Data.Posts == nil {
|
||||
return nil
|
||||
}
|
||||
for _, post := range postsResp.Data.Posts {
|
||||
if post.ID == postID {
|
||||
return &post
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestE2E_NewUserOnboarding(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("new_user_onboarding", func(t *testing.T) {
|
||||
username := uniqueUsername(t, "newuser")
|
||||
email := uniqueEmail(t, "newuser")
|
||||
password := "Password123!"
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
statusCode := ctx.registerUserExpectStatus(t, username, email, password)
|
||||
if statusCode != http.StatusCreated {
|
||||
t.Fatalf("Expected registration to succeed, got status %d", statusCode)
|
||||
}
|
||||
|
||||
verificationToken := ctx.server.EmailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
t.Fatalf("Expected verification token")
|
||||
}
|
||||
|
||||
ctx.confirmEmail(t, verificationToken)
|
||||
|
||||
authClient := ctx.loginUser(t, username, password)
|
||||
if authClient.Token == "" {
|
||||
t.Fatalf("Expected login to succeed after email verification")
|
||||
}
|
||||
|
||||
createdPost := authClient.CreatePost(t, "My First Post", "https://example.com/first", "This is my first post content")
|
||||
if createdPost.ID == 0 {
|
||||
t.Errorf("Expected post creation to succeed")
|
||||
}
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed, got failure: %s", voteResp.Message)
|
||||
}
|
||||
|
||||
profile := authClient.GetProfile(t)
|
||||
if profile.Data.Username != username {
|
||||
t.Errorf("Expected profile username to match, got '%s'", profile.Data.Username)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ReturningUserSession(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("returning_user_session", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "returning", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
if postsResp == nil {
|
||||
t.Errorf("Expected posts response")
|
||||
}
|
||||
|
||||
post1 := authClient.CreatePost(t, "Post 1", "https://example.com/post1", "Content 1")
|
||||
post2 := authClient.CreatePost(t, "Post 2", "https://example.com/post2", "Content 2")
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, post1.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed")
|
||||
}
|
||||
|
||||
voteResp = authClient.VoteOnPost(t, post2.ID, "down")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed")
|
||||
}
|
||||
|
||||
postsResp = authClient.GetPosts(t)
|
||||
if postsResp == nil || len(postsResp.Data.Posts) == 0 {
|
||||
t.Errorf("Expected to retrieve posts")
|
||||
}
|
||||
|
||||
authClient.Logout(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PowerUserWorkflow(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("power_user_workflow", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "poweruser", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
var postIDs []uint
|
||||
for i := 1; i <= 5; i++ {
|
||||
post := authClient.CreatePost(t,
|
||||
uniqueTestID(t)+" Post "+fmt.Sprintf("%d", i),
|
||||
"https://example.com/power"+uniqueTestID(t)+fmt.Sprintf("%d", i),
|
||||
"Content "+fmt.Sprintf("%d", i))
|
||||
postIDs = append(postIDs, post.ID)
|
||||
}
|
||||
|
||||
for i, postID := range postIDs {
|
||||
voteType := "up"
|
||||
if i%2 == 0 {
|
||||
voteType = "down"
|
||||
}
|
||||
voteResp := authClient.VoteOnPost(t, postID, voteType)
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed on post %d", postID)
|
||||
}
|
||||
}
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
firstPost := findPostInList(postsResp, postIDs[0])
|
||||
if firstPost == nil {
|
||||
t.Fatalf("Expected to retrieve first post")
|
||||
}
|
||||
|
||||
authClient.UpdatePost(t, postIDs[0], "Updated Title", "https://example.com/updated", "Updated content")
|
||||
updatedPostsResp := authClient.GetPosts(t)
|
||||
updatedPost := findPostInList(updatedPostsResp, postIDs[0])
|
||||
if updatedPost == nil {
|
||||
t.Fatalf("Expected to retrieve updated post")
|
||||
}
|
||||
if updatedPost.Title != "Updated Title" {
|
||||
t.Errorf("Expected post title to be updated, got '%s'", updatedPost.Title)
|
||||
}
|
||||
|
||||
authClient.DeletePost(t, postIDs[len(postIDs)-1])
|
||||
finalPostsResp := authClient.GetPosts(t)
|
||||
deletedPost := findPostInList(finalPostsResp, postIDs[len(postIDs)-1])
|
||||
if deletedPost != nil {
|
||||
t.Errorf("Expected deleted post to not be accessible")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PasswordResetFlowRealistic(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("password_reset_flow", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "resetflow", "Password123!")
|
||||
_ = ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, createdUser.Email, testutils.GenerateTestIP())
|
||||
|
||||
resetToken := ctx.server.EmailSender.PasswordResetToken()
|
||||
if resetToken == "" {
|
||||
t.Fatalf("Expected password reset token")
|
||||
}
|
||||
|
||||
newPassword := "NewPassword456!"
|
||||
statusCode := testutils.ResetPassword(t, ctx.client, ctx.baseURL, resetToken, newPassword, testutils.GenerateTestIP())
|
||||
if statusCode != http.StatusOK {
|
||||
t.Fatalf("Expected password reset to succeed, got status %d", statusCode)
|
||||
}
|
||||
|
||||
oldLoginStatus := ctx.loginExpectStatus(t, createdUser.Username, "Password123!", http.StatusUnauthorized)
|
||||
if oldLoginStatus == http.StatusOK {
|
||||
t.Log("Old password may still work briefly (acceptable)")
|
||||
}
|
||||
|
||||
newClient := ctx.loginUser(t, createdUser.Username, newPassword)
|
||||
if newClient.Token == "" {
|
||||
t.Errorf("Expected login with new password to succeed")
|
||||
}
|
||||
|
||||
newClient.UpdatePassword(t, newPassword, "AnotherPassword789!")
|
||||
finalClient := ctx.loginUser(t, createdUser.Username, "AnotherPassword789!")
|
||||
if finalClient.Token == "" {
|
||||
t.Errorf("Expected login with final password to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PostLifecycle(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("post_lifecycle", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "lifecycle", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
createdPost := authClient.CreatePost(t, "Original Title", "https://example.com/lifecycle", "Original content")
|
||||
if createdPost.ID == 0 {
|
||||
t.Fatalf("Expected post creation to succeed")
|
||||
}
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to succeed")
|
||||
}
|
||||
|
||||
authClient.UpdatePost(t, createdPost.ID, "Updated Title", "https://example.com/lifecycle", "Updated content")
|
||||
postsResp := authClient.GetPosts(t)
|
||||
updatedPost := findPostInList(postsResp, createdPost.ID)
|
||||
if updatedPost == nil {
|
||||
t.Fatalf("Expected to retrieve updated post")
|
||||
}
|
||||
if updatedPost.Title != "Updated Title" {
|
||||
t.Errorf("Expected post to be updated")
|
||||
}
|
||||
|
||||
voteResp = authClient.VoteOnPost(t, createdPost.ID, "down")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote change to succeed")
|
||||
}
|
||||
|
||||
authClient.UpdatePost(t, createdPost.ID, "Final Title", "https://example.com/lifecycle", "Final content")
|
||||
finalPostsResp := authClient.GetPosts(t)
|
||||
finalPost := findPostInList(finalPostsResp, createdPost.ID)
|
||||
if finalPost == nil {
|
||||
t.Fatalf("Expected to retrieve final post")
|
||||
}
|
||||
if finalPost.Title != "Final Title" {
|
||||
t.Errorf("Expected post to be updated again")
|
||||
}
|
||||
|
||||
authClient.DeletePost(t, createdPost.ID)
|
||||
deletedPostsResp := authClient.GetPosts(t)
|
||||
deletedPost := findPostInList(deletedPostsResp, createdPost.ID)
|
||||
if deletedPost != nil {
|
||||
t.Errorf("Expected deleted post to not be accessible")
|
||||
}
|
||||
|
||||
recreatedPost := authClient.CreatePost(t, "Recreated Title", "https://example.com/lifecycle-recreated", "Recreated content")
|
||||
if recreatedPost.ID == 0 {
|
||||
t.Errorf("Expected post recreation to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_VotePatterns(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("vote_patterns", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "votepattern", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
post := authClient.CreatePost(t, "Vote Test Post", "https://example.com/vote", "Content")
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, post.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected upvote to succeed")
|
||||
}
|
||||
|
||||
userVote := authClient.GetUserVote(t, post.ID)
|
||||
if userVote == nil || userVote.Data == nil {
|
||||
t.Errorf("Expected to retrieve user vote")
|
||||
}
|
||||
|
||||
voteResp = authClient.VoteOnPost(t, post.ID, "down")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected downvote to succeed")
|
||||
}
|
||||
|
||||
voteResp = authClient.VoteOnPost(t, post.ID, "none")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote removal to succeed")
|
||||
}
|
||||
|
||||
userVote = authClient.GetUserVote(t, post.ID)
|
||||
if userVote != nil && userVote.Data != nil {
|
||||
voteData, ok := userVote.Data.(map[string]any)
|
||||
if ok {
|
||||
if voteType, exists := voteData["type"]; exists && voteType != nil && voteType != "none" {
|
||||
t.Errorf("Expected vote to be removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
voteResp = authClient.VoteOnPost(t, post.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected upvote after removal to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ProfileUpdateFlow(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("profile_update_flow", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "profile", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
_ = authClient.GetProfile(t)
|
||||
|
||||
newUsername := uniqueUsername(t, "updated")
|
||||
authClient.UpdateUsername(t, newUsername)
|
||||
updatedProfile := authClient.GetProfile(t)
|
||||
if updatedProfile.Data.Username != newUsername {
|
||||
t.Errorf("Expected username to be updated, got '%s'", updatedProfile.Data.Username)
|
||||
}
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
newEmail := uniqueEmail(t, "updated")
|
||||
authClient.UpdateEmail(t, newEmail)
|
||||
emailProfile := authClient.GetProfile(t)
|
||||
normalizedNewEmail := strings.ToLower(strings.TrimSpace(newEmail))
|
||||
if emailProfile.Data.Email != normalizedNewEmail {
|
||||
t.Errorf("Expected email to be updated, got '%s'", emailProfile.Data.Email)
|
||||
}
|
||||
|
||||
verificationToken := ctx.server.EmailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
t.Fatalf("Expected verification token after email update")
|
||||
}
|
||||
ctx.confirmEmail(t, verificationToken)
|
||||
|
||||
authClient.UpdatePassword(t, "Password123!", "NewPassword999!")
|
||||
passwordClient := ctx.loginUser(t, newUsername, "NewPassword999!")
|
||||
if passwordClient.Token == "" {
|
||||
t.Errorf("Expected login with new password to succeed")
|
||||
}
|
||||
|
||||
finalProfile := passwordClient.GetProfile(t)
|
||||
if finalProfile.Data.Username != newUsername {
|
||||
t.Errorf("Expected username to remain updated, got '%s'", finalProfile.Data.Username)
|
||||
}
|
||||
if finalProfile.Data.Email != normalizedNewEmail {
|
||||
t.Errorf("Expected email to remain updated, got '%s'", finalProfile.Data.Email)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_MultiUserInteraction(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("multi_user_interaction", func(t *testing.T) {
|
||||
userA := ctx.createUserWithCleanup(t, "usera", "Password123!")
|
||||
userB := ctx.createUserWithCleanup(t, "userb", "Password123!")
|
||||
|
||||
clientA := ctx.loginUser(t, userA.Username, userA.Password)
|
||||
clientB := ctx.loginUser(t, userB.Username, userB.Password)
|
||||
|
||||
post := clientA.CreatePost(t, "User A's Post", "https://example.com/usera", "Content from User A")
|
||||
if post.ID == 0 {
|
||||
t.Fatalf("Expected post creation to succeed")
|
||||
}
|
||||
|
||||
voteResp := clientB.VoteOnPost(t, post.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected User B to vote on User A's post")
|
||||
}
|
||||
|
||||
clientA.UpdatePost(t, post.ID, "Updated by User A", "https://example.com/usera", "Updated content")
|
||||
postsResp := clientB.GetPosts(t)
|
||||
updatedPost := findPostInList(postsResp, post.ID)
|
||||
if updatedPost == nil {
|
||||
t.Fatalf("Expected to retrieve updated post")
|
||||
}
|
||||
if updatedPost.Title != "Updated by User A" {
|
||||
t.Errorf("Expected User B to see updated post")
|
||||
}
|
||||
|
||||
voteResp = clientB.VoteOnPost(t, post.ID, "down")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected User B to change vote")
|
||||
}
|
||||
|
||||
finalPostsResp := clientA.GetPosts(t)
|
||||
finalPost := findPostInList(finalPostsResp, post.ID)
|
||||
if finalPost == nil {
|
||||
t.Errorf("Expected User A to retrieve final post")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ContentDiscovery(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("content_discovery", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "discovery", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
post1 := authClient.CreatePost(t, "Golang Tutorial", "https://example.com/golang", "Learn Go programming")
|
||||
post2 := authClient.CreatePost(t, "Python Guide", "https://example.com/python", "Python programming guide")
|
||||
post3 := authClient.CreatePost(t, "Rust Basics", "https://example.com/rust", "Rust programming basics")
|
||||
|
||||
authClient.VoteOnPost(t, post1.ID, "up")
|
||||
authClient.VoteOnPost(t, post2.ID, "up")
|
||||
authClient.VoteOnPost(t, post3.ID, "down")
|
||||
|
||||
searchResp := authClient.SearchPosts(t, "Golang")
|
||||
if searchResp == nil || len(searchResp.Data.Posts) == 0 {
|
||||
t.Errorf("Expected search to find posts")
|
||||
}
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
if postsResp == nil || len(postsResp.Data.Posts) == 0 {
|
||||
t.Errorf("Expected to retrieve posts")
|
||||
}
|
||||
|
||||
authClient.VoteOnPost(t, post1.ID, "up")
|
||||
updatedPostsResp := authClient.GetPosts(t)
|
||||
updatedPost := findPostInList(updatedPostsResp, post1.ID)
|
||||
if updatedPost == nil {
|
||||
t.Errorf("Expected to retrieve updated post")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SessionPersistence(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("session_persistence", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "session", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
profile1 := authClient.GetProfile(t)
|
||||
if profile1.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected first profile request to succeed")
|
||||
}
|
||||
|
||||
ctx.assertEventually(t, func() bool {
|
||||
profile2 := authClient.GetProfile(t)
|
||||
return profile2 != nil && profile2.Data.Username == createdUser.Username
|
||||
}, 2*time.Second)
|
||||
|
||||
profile2 := authClient.GetProfile(t)
|
||||
if profile2.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected second profile request to succeed")
|
||||
}
|
||||
|
||||
postsResp1 := authClient.GetPosts(t)
|
||||
postsResp2 := authClient.GetPosts(t)
|
||||
|
||||
if postsResp1 == nil || postsResp2 == nil {
|
||||
t.Errorf("Expected multiple requests with same session to work")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentRequestsWithSameSession(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_requests_same_session", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "concurrent", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
results := make(chan bool, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
go func() {
|
||||
profile := authClient.GetProfile(t)
|
||||
results <- (profile != nil && profile.Data.Username == createdUser.Username)
|
||||
}()
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for i := 0; i < 5; i++ {
|
||||
if <-results {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
t.Errorf("Expected at least some concurrent requests to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_UserAgentHeaders(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("user_agent_headers", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "useragent", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
userAgents := []string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
"Mozilla/5.0 (X11; Linux x86_64)",
|
||||
"Go-http-client/1.1",
|
||||
}
|
||||
|
||||
for _, ua := range userAgents {
|
||||
request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").
|
||||
WithAuth(authClient.Token).
|
||||
WithHeader("User-Agent", ua).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create request with User-Agent: %s", ua)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Errorf("Request failed with User-Agent %s: %v", ua, err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 with User-Agent %s, got %d", ua, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RefererHeaders(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("referer_headers", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "referer", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
referers := []string{
|
||||
"https://example.com/page1",
|
||||
"https://example.com/page2",
|
||||
"http://localhost:3000",
|
||||
"",
|
||||
}
|
||||
|
||||
for _, referer := range referers {
|
||||
builder := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").
|
||||
WithAuth(authClient.Token)
|
||||
if referer != "" {
|
||||
builder = builder.WithHeader("Referer", referer)
|
||||
}
|
||||
request, err := builder.Build()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create request with Referer: %s", referer)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Errorf("Request failed with Referer %s: %v", referer, err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 with Referer %s, got %d", referer, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_RapidSuccessiveActions(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("rapid_successive_actions", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "rapid", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
post := authClient.CreatePost(t, "Rapid Vote Test", "https://example.com/rapid", "Content")
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
voteType := "up"
|
||||
if i%2 == 0 {
|
||||
voteType = "down"
|
||||
}
|
||||
voteResp := authClient.VoteOnPost(t, post.ID, voteType)
|
||||
if !voteResp.Success {
|
||||
t.Logf("Vote %d may have been rate limited (acceptable)", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
finalPostsResp := authClient.GetPosts(t)
|
||||
finalPost := findPostInList(finalPostsResp, post.ID)
|
||||
if finalPost == nil {
|
||||
t.Errorf("Expected to retrieve post after rapid votes")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_LongRunningSession(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("long_running_session", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "longsession", "Password123!")
|
||||
authClient := ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
profile1 := authClient.GetProfile(t)
|
||||
if profile1 == nil {
|
||||
t.Fatalf("Expected initial profile request to succeed")
|
||||
}
|
||||
|
||||
post := authClient.CreatePost(t, "Long Session Post", "https://example.com/long", "Content")
|
||||
if post.ID == 0 {
|
||||
t.Errorf("Expected post creation after delay to succeed")
|
||||
}
|
||||
|
||||
profile2 := authClient.GetProfile(t)
|
||||
if profile2 == nil || profile2.Data.Username != createdUser.Username {
|
||||
t.Errorf("Expected profile request after delay to succeed")
|
||||
}
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, post.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote after delay to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_CompleteUserJourney(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("complete_user_journey", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
createdPost := authClient.CreatePost(t, "Test Post", "https://example.com/test", "This is a test post content")
|
||||
|
||||
voteResp := authClient.VoteOnPost(t, createdPost.ID, "up")
|
||||
if !voteResp.Success {
|
||||
t.Errorf("Expected vote to be successful, got failure: %s", voteResp.Message)
|
||||
}
|
||||
|
||||
postsResp := authClient.GetPosts(t)
|
||||
assertPostInList(t, postsResp, createdPost)
|
||||
|
||||
searchResp := authClient.SearchPosts(t, "test")
|
||||
assertPostInList(t, searchResp, createdPost)
|
||||
|
||||
authClient.Logout(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ErrorHandlingWorkflows(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("unauthenticated_user_workflow", func(t *testing.T) {
|
||||
request, err := http.NewRequest("POST", ctx.baseURL+"/api/posts", bytes.NewReader([]byte(`{"title":"Test","url":"https://example.com"}`)))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
testutils.WithStandardHeaders(request)
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected 401 for unauthenticated post creation, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
request, err = testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err = ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected 401 for unauthenticated profile access, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid_registration_workflow", func(t *testing.T) {
|
||||
invalidData := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{
|
||||
name: "empty_username",
|
||||
body: []byte(`{"username":"","email":"test@example.com","password":"ValidPass123!"}`),
|
||||
},
|
||||
{
|
||||
name: "invalid_email",
|
||||
body: []byte(`{"username":"testuser","email":"invalid-email","password":"ValidPass123!"}`),
|
||||
},
|
||||
{
|
||||
name: "weak_password",
|
||||
body: []byte(`{"username":"testuser","email":"test@example.com","password":"123"}`),
|
||||
},
|
||||
{
|
||||
name: "malformed_json",
|
||||
body: []byte(`{"username": "test", "password": }`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range invalidData {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithBody(bytes.NewReader(test.body)).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
||||
t.Errorf("Expected invalid registration to fail, got success status %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ConcurrentUserWorkflows(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("concurrent_user_workflows", func(t *testing.T) {
|
||||
users := ctx.createMultipleUsersWithCleanup(t, 3, "concurrent", "StrongPass123!")
|
||||
|
||||
type result struct {
|
||||
userID uint
|
||||
err error
|
||||
}
|
||||
|
||||
results := make(chan result, len(users))
|
||||
var wg sync.WaitGroup
|
||||
done := make(chan struct{})
|
||||
|
||||
for _, user := range users {
|
||||
u := user
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var err error
|
||||
authClient, loginErr := ctx.loginUserSafe(t, u.Username, u.Password)
|
||||
if loginErr != nil || authClient == nil || authClient.Token == "" {
|
||||
err = fmt.Errorf("User %s failed to login", u.Username)
|
||||
} else {
|
||||
postURL := fmt.Sprintf("https://example.com/concurrent/%d", u.ID)
|
||||
post, postErr := authClient.CreatePostSafe("Concurrent Post", postURL, "Content")
|
||||
if postErr != nil || post == nil || post.ID == 0 {
|
||||
err = fmt.Errorf("User %s failed to create post: %v", u.Username, postErr)
|
||||
} else {
|
||||
voteResp, voteErr := authClient.VoteOnPostSafe(post.ID, "up")
|
||||
if voteErr != nil || voteResp == nil || !voteResp.Success {
|
||||
err = fmt.Errorf("User %s failed to vote: %v", u.Username, voteErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case results <- result{userID: u.ID, err: err}:
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
timeout := time.After(10 * time.Second)
|
||||
successCount := 0
|
||||
receivedCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case res, ok := <-results:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
receivedCount++
|
||||
if res.err != nil {
|
||||
t.Errorf("Concurrent operation error for user %d: %v", res.userID, res.err)
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
if receivedCount >= len(users) {
|
||||
return
|
||||
}
|
||||
case <-timeout:
|
||||
close(done)
|
||||
t.Errorf("Timeout waiting for concurrent operations to complete")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SystemMonitoringWorkflows(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("system_monitoring_workflows", func(t *testing.T) {
|
||||
t.Run("health_endpoint", func(t *testing.T) {
|
||||
health := getHealth(t, ctx.client, ctx.baseURL)
|
||||
if !health.Success {
|
||||
t.Errorf("Expected health check to succeed, got failure: %s", health.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("metrics_endpoint", func(t *testing.T) {
|
||||
metrics := getMetrics(t, ctx.client, ctx.baseURL)
|
||||
if metrics == nil {
|
||||
t.Errorf("Expected metrics to be returned")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_AccountDeletion(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("account_deletion_flow", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
_ = authClient.CreatePost(t, "Test Post", "https://example.com/test", "Test content")
|
||||
|
||||
statusCode, deletionResp := ctx.requestAccountDeletionExpectStatus(t, authClient.Token, http.StatusOK)
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
statusCode = retryOnRateLimit(t, 3, func() int {
|
||||
code, _ := ctx.requestAccountDeletionExpectStatus(t, authClient.Token, http.StatusOK)
|
||||
return code
|
||||
})
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
t.Skip("Skipping account deletion flow test: rate limited after retries")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if deletionResp == nil {
|
||||
t.Fatalf("Expected account deletion response, got nil")
|
||||
}
|
||||
if !deletionResp.Success {
|
||||
t.Errorf("Expected account deletion request to be successful, got %v", deletionResp.Success)
|
||||
}
|
||||
if deletionResp.Message == "" {
|
||||
t.Errorf("Expected deletion message to be present, got empty string")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package fuzz
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
fuzzDBOnce sync.Once
|
||||
fuzzDB *gorm.DB
|
||||
fuzzDBErr error
|
||||
)
|
||||
|
||||
func GetFuzzDB() (*gorm.DB, error) {
|
||||
fuzzDBOnce.Do(func() {
|
||||
dbName := "file:memdb_fuzz?mode=memory&cache=shared&_journal_mode=WAL&_synchronous=NORMAL"
|
||||
fuzzDB, fuzzDBErr = gorm.Open(sqlite.Open(dbName), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if fuzzDBErr == nil {
|
||||
fuzzDBErr = fuzzDB.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
email_verified INTEGER DEFAULT 0 NOT NULL,
|
||||
email_verified_at DATETIME,
|
||||
email_verification_token TEXT,
|
||||
email_verification_sent_at DATETIME,
|
||||
password_reset_token TEXT,
|
||||
password_reset_sent_at DATETIME,
|
||||
password_reset_expires_at DATETIME,
|
||||
locked INTEGER DEFAULT 0,
|
||||
session_version INTEGER DEFAULT 1 NOT NULL,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
deleted_at DATETIME
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT UNIQUE,
|
||||
content TEXT,
|
||||
author_id INTEGER,
|
||||
author_name TEXT,
|
||||
up_votes INTEGER DEFAULT 0,
|
||||
down_votes INTEGER DEFAULT 0,
|
||||
score INTEGER DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
deleted_at DATETIME,
|
||||
FOREIGN KEY(author_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
post_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
vote_hash TEXT,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(post_id) REFERENCES posts(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS account_deletion_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
`).Error
|
||||
}
|
||||
})
|
||||
return fuzzDB, fuzzDBErr
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package fuzz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type FuzzTestHelper struct{}
|
||||
|
||||
func NewFuzzTestHelper() *FuzzTestHelper {
|
||||
return &FuzzTestHelper{}
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) RunBasicFuzzTest(f *testing.F, testFunc func(t *testing.T, input string)) {
|
||||
f.Add("test input")
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
if !utf8.ValidString(input) {
|
||||
return
|
||||
}
|
||||
testFunc(t, input)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) RunValidationFuzzTest(f *testing.F, validateFunc func(string) error) {
|
||||
h.RunBasicFuzzTest(f, func(t *testing.T, input string) {
|
||||
err := validateFunc(input)
|
||||
_ = err
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) RunSanitizationFuzzTest(f *testing.F, sanitizeFunc func(string) string) {
|
||||
h.RunBasicFuzzTest(f, func(t *testing.T, input string) {
|
||||
result := sanitizeFunc(input)
|
||||
if !utf8.ValidString(result) {
|
||||
t.Fatal("Sanitized result contains invalid UTF-8")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) RunSanitizationFuzzTestWithValidation(f *testing.F, sanitizeFunc func(string) string, validateFunc func(string) bool) {
|
||||
h.RunBasicFuzzTest(f, func(t *testing.T, input string) {
|
||||
result := sanitizeFunc(input)
|
||||
if !utf8.ValidString(result) {
|
||||
t.Fatal("Sanitized result contains invalid UTF-8")
|
||||
}
|
||||
if validateFunc != nil {
|
||||
if !validateFunc(result) {
|
||||
t.Fatal("Sanitized result failed validation")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) RunJSONFuzzTest(f *testing.F, testCases []map[string]any) {
|
||||
h.RunBasicFuzzTest(f, func(t *testing.T, input string) {
|
||||
for _, tc := range testCases {
|
||||
body, ok := tc["body"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
encoded, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
encodedStr := string(encoded)
|
||||
body = strings.ReplaceAll(body, "FUZZED_INPUT", encodedStr)
|
||||
|
||||
var result map[string]any
|
||||
err = json.Unmarshal([]byte(body), &result)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) RunHTTPFuzzTest(f *testing.F, testCases []HTTPFuzzTestCase) {
|
||||
h.RunBasicFuzzTest(f, func(t *testing.T, input string) {
|
||||
for _, tc := range testCases {
|
||||
|
||||
sanitized := h.sanitizeForURL(input)
|
||||
|
||||
url := strings.ReplaceAll(tc.URL, "FUZZED_INPUT", sanitized)
|
||||
body := strings.ReplaceAll(tc.Body, "FUZZED_INPUT", sanitized)
|
||||
|
||||
req := httptest.NewRequest(tc.Method, url, bytes.NewBufferString(body))
|
||||
|
||||
for name, value := range tc.Headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
|
||||
h.validateHTTPRequest(t, req)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) sanitizeForURL(input string) string {
|
||||
sanitized := strings.ReplaceAll(input, "\n", "")
|
||||
sanitized = strings.ReplaceAll(sanitized, "\r", "")
|
||||
sanitized = strings.ReplaceAll(sanitized, "\t", "")
|
||||
sanitized = url.QueryEscape(sanitized)
|
||||
sanitized = strings.ReplaceAll(sanitized, "+", "%20")
|
||||
|
||||
if len(sanitized) > 100 {
|
||||
sanitized = sanitized[:100]
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
type HTTPFuzzTestCase struct {
|
||||
Name string
|
||||
Method string
|
||||
URL string
|
||||
Headers map[string]string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) validateHTTPRequest(t *testing.T, req *http.Request) {
|
||||
pathParts := strings.Split(req.URL.Path, "/")
|
||||
for _, part := range pathParts {
|
||||
if !utf8.ValidString(part) {
|
||||
t.Fatal("Path contains invalid UTF-8")
|
||||
}
|
||||
}
|
||||
|
||||
for name, values := range req.URL.Query() {
|
||||
if !utf8.ValidString(name) {
|
||||
t.Fatal("Query parameter name contains invalid UTF-8")
|
||||
}
|
||||
for _, value := range values {
|
||||
if !utf8.ValidString(value) {
|
||||
t.Fatal("Query parameter value contains invalid UTF-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name, values := range req.Header {
|
||||
if !utf8.ValidString(name) {
|
||||
t.Fatal("Header name contains invalid UTF-8")
|
||||
}
|
||||
for _, value := range values {
|
||||
if !utf8.ValidString(value) {
|
||||
t.Fatal("Header value contains invalid UTF-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) RunIntegrationFuzzTest(f *testing.F, testFunc func(t *testing.T, input string)) {
|
||||
h.RunBasicFuzzTest(f, func(t *testing.T, input string) {
|
||||
|
||||
if len(input) > 1000 {
|
||||
input = input[:1000]
|
||||
}
|
||||
|
||||
testFunc(t, input)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) GetCommonAuthTestCases(input string) []HTTPFuzzTestCase {
|
||||
return []HTTPFuzzTestCase{
|
||||
{
|
||||
Name: "auth_register",
|
||||
Method: "POST",
|
||||
URL: "/api/auth/register",
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
Body: `{"username":"FUZZED_INPUT","email":"test@example.com","password":"test123"}`,
|
||||
},
|
||||
{
|
||||
Name: "auth_login",
|
||||
Method: "POST",
|
||||
URL: "/api/auth/login",
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
Body: `{"username":"FUZZED_INPUT","password":"test123"}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) GetCommonPostTestCases(input string) []HTTPFuzzTestCase {
|
||||
return []HTTPFuzzTestCase{
|
||||
{
|
||||
Name: "post_create",
|
||||
Method: "POST",
|
||||
URL: "/api/posts",
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer FUZZED_INPUT",
|
||||
},
|
||||
Body: `{"title":"FUZZED_INPUT","url":"https://example.com","content":"test"}`,
|
||||
},
|
||||
{
|
||||
Name: "post_search",
|
||||
Method: "GET",
|
||||
URL: "/api/posts/search?q=FUZZED_INPUT",
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FuzzTestHelper) GetCommonVoteTestCases(input string) []HTTPFuzzTestCase {
|
||||
return []HTTPFuzzTestCase{
|
||||
{
|
||||
Name: "vote_cast",
|
||||
Method: "POST",
|
||||
URL: "/api/posts/1/vote",
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer FUZZED_INPUT",
|
||||
},
|
||||
Body: `{"type":"FUZZED_INPUT"}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
||||
package fuzz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"goyco/internal/handlers"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func FuzzIntegrationHandlers(f *testing.F) {
|
||||
f.Add("testuser")
|
||||
f.Add("test@example.com")
|
||||
f.Add("password123")
|
||||
f.Add("")
|
||||
f.Add("<script>alert('xss')</script>")
|
||||
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
if len(input) > 500 {
|
||||
input = input[:500]
|
||||
}
|
||||
|
||||
if !isValidUTF8(input) {
|
||||
return
|
||||
}
|
||||
|
||||
db := testutils.NewTestDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
}()
|
||||
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
postRepo := repositories.NewPostRepository(db)
|
||||
voteRepo := repositories.NewVoteRepository(db)
|
||||
deletionRepo := repositories.NewAccountDeletionRepository(db)
|
||||
refreshTokenRepo := repositories.NewRefreshTokenRepository(db)
|
||||
emailSender := &testutils.MockEmailSender{}
|
||||
|
||||
authService, err := services.NewAuthFacadeForTest(testutils.AppTestConfig, userRepo, postRepo, deletionRepo, refreshTokenRepo, emailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
|
||||
voteService := services.NewVoteService(voteRepo, postRepo, db)
|
||||
titleFetcher := &testutils.MockTitleFetcher{}
|
||||
|
||||
authHandler := handlers.NewAuthHandler(authService, userRepo)
|
||||
postHandler := handlers.NewPostHandler(postRepo, titleFetcher, voteService)
|
||||
apiHandler := handlers.NewAPIHandler(testutils.AppTestConfig, postRepo, userRepo, voteService)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.Logging(false))
|
||||
router.Use(middleware.SecurityHeadersMiddleware())
|
||||
router.Use(middleware.GeneralRateLimitMiddleware())
|
||||
|
||||
router.Route("/api", func(r chi.Router) {
|
||||
r.Post("/auth/register", authHandler.Register)
|
||||
r.Post("/auth/login", authHandler.Login)
|
||||
r.Get("/posts/search", postHandler.SearchPosts)
|
||||
r.Get("/posts", postHandler.GetPosts)
|
||||
|
||||
r.Group(func(protected chi.Router) {
|
||||
protected.Use(middleware.NewAuth(authService))
|
||||
protected.Get("/auth/me", authHandler.Me)
|
||||
protected.Post("/posts", postHandler.CreatePost)
|
||||
})
|
||||
})
|
||||
|
||||
router.Get("/health", apiHandler.GetHealth)
|
||||
|
||||
t.Run("register_endpoint", func(t *testing.T) {
|
||||
username := input[:min(len(input), 50)]
|
||||
email := input[:min(len(input), 50)] + "@example.com"
|
||||
password := input[:min(len(input), 128)]
|
||||
if len(password) < 8 {
|
||||
password = password + "12345678"
|
||||
}
|
||||
|
||||
registerBody := fmt.Sprintf(`{"username":"%s","email":"%s","password":"%s"}`,
|
||||
escapeJSON(username), escapeJSON(email), escapeJSON(password))
|
||||
|
||||
req, _ := http.NewRequest("POST", "/api/auth/register", bytes.NewBufferString(registerBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code == 0 {
|
||||
t.Fatal("Handler should return a status code")
|
||||
}
|
||||
|
||||
if resp.Code != http.StatusCreated && resp.Code != http.StatusBadRequest {
|
||||
t.Logf("Unexpected status code %d for register (expected 201 or 400)", resp.Code)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("Response should be valid JSON: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search_endpoint", func(t *testing.T) {
|
||||
query := input[:min(len(input), 200)]
|
||||
escapedQuery := url.QueryEscape(query)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/posts/search?q="+escapedQuery, nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code == 0 {
|
||||
t.Fatal("Handler should return a status code")
|
||||
}
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Logf("Unexpected status code %d for search (expected 200)", resp.Code)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("Response should be valid JSON: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzIntegrationServices(f *testing.F) {
|
||||
f.Add("testuser")
|
||||
f.Add("test@example.com")
|
||||
f.Add("password123")
|
||||
f.Add("")
|
||||
f.Add("a")
|
||||
f.Add(strings.Repeat("x", 100))
|
||||
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
if len(input) > 200 {
|
||||
input = input[:200]
|
||||
}
|
||||
|
||||
if !utf8.ValidString(input) {
|
||||
return
|
||||
}
|
||||
|
||||
db := testutils.NewTestDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
}()
|
||||
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
postRepo := repositories.NewPostRepository(db)
|
||||
deletionRepo := repositories.NewAccountDeletionRepository(db)
|
||||
refreshTokenRepo := repositories.NewRefreshTokenRepository(db)
|
||||
emailSender := &testutils.MockEmailSender{}
|
||||
|
||||
authService, err := services.NewAuthFacadeForTest(testutils.AppTestConfig, userRepo, postRepo, deletionRepo, refreshTokenRepo, emailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
|
||||
usernameLen := len(input)
|
||||
if usernameLen > 50 {
|
||||
usernameLen = 50
|
||||
}
|
||||
username := input[:usernameLen]
|
||||
email := input[:usernameLen] + "@example.com"
|
||||
|
||||
passwordLen := len(input)
|
||||
if passwordLen > 128 {
|
||||
passwordLen = 128
|
||||
}
|
||||
password := input[:passwordLen]
|
||||
|
||||
if len(password) < 8 {
|
||||
password = password + "12345678"
|
||||
}
|
||||
|
||||
result, err := authService.Register(username, email, password)
|
||||
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "panic") || strings.Contains(err.Error(), "nil pointer") {
|
||||
t.Fatalf("Registration should not panic: %v", err)
|
||||
}
|
||||
} else {
|
||||
if result.User == nil {
|
||||
t.Fatal("Registration result should contain a user")
|
||||
}
|
||||
if result.User.Username != username {
|
||||
t.Fatalf("Expected username %q, got %q", username, result.User.Username)
|
||||
}
|
||||
if !strings.EqualFold(result.User.Email, email) {
|
||||
t.Fatalf("Expected email %q, got %q", email, result.User.Email)
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
loginResult, loginErr := authService.Login(username, password)
|
||||
if loginErr == nil {
|
||||
if loginResult.User == nil {
|
||||
t.Fatal("Login result should contain a user")
|
||||
}
|
||||
if loginResult.User.Username != username {
|
||||
t.Fatalf("Expected username %q, got %q", username, loginResult.User.Username)
|
||||
}
|
||||
if loginResult.AccessToken == "" {
|
||||
t.Fatal("Login result should contain an access token")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzIntegrationRepositories(f *testing.F) {
|
||||
helper := NewFuzzTestHelper()
|
||||
helper.RunIntegrationFuzzTest(f, func(t *testing.T, fuzzedData string) {
|
||||
searchQuery := fuzzedData
|
||||
if len(searchQuery) > 100 {
|
||||
searchQuery = searchQuery[:100]
|
||||
}
|
||||
|
||||
sanitizer := repositories.NewSearchSanitizer()
|
||||
sanitizedQuery, err := sanitizer.SanitizeSearchQuery(searchQuery)
|
||||
|
||||
if err == nil {
|
||||
if !utf8.ValidString(sanitizedQuery) {
|
||||
t.Fatal("String contains invalid UTF-8")
|
||||
}
|
||||
|
||||
validationErr := sanitizer.ValidateSearchQuery(sanitizedQuery)
|
||||
_ = validationErr
|
||||
}
|
||||
|
||||
username := fuzzedData
|
||||
email := fuzzedData + "@example.com"
|
||||
|
||||
if len(username) > 50 {
|
||||
username = username[:50]
|
||||
}
|
||||
if len(email) > 100 {
|
||||
email = email[:100]
|
||||
}
|
||||
|
||||
if !utf8.ValidString(username) {
|
||||
t.Fatal("String contains invalid UTF-8")
|
||||
}
|
||||
if !utf8.ValidString(email) {
|
||||
t.Fatal("String contains invalid UTF-8")
|
||||
}
|
||||
|
||||
postTitle := fuzzedData
|
||||
postContent := fuzzedData
|
||||
|
||||
if len(postTitle) > 200 {
|
||||
postTitle = postTitle[:200]
|
||||
}
|
||||
if len(postContent) > 1000 {
|
||||
postContent = postContent[:1000]
|
||||
}
|
||||
|
||||
if !utf8.ValidString(postTitle) {
|
||||
t.Fatal("String contains invalid UTF-8")
|
||||
}
|
||||
if !utf8.ValidString(postContent) {
|
||||
t.Fatal("String contains invalid UTF-8")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isValidUTF8(s string) bool {
|
||||
for _, r := range s {
|
||||
if r == utf8.RuneError {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func escapeJSON(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "\"", "\\\"")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||
s = strings.ReplaceAll(s, "\t", "\\t")
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package fuzz
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"goyco/internal/repositories"
|
||||
)
|
||||
|
||||
func FuzzSearchRepository(f *testing.F) {
|
||||
f.Add("test query")
|
||||
f.Add("")
|
||||
f.Add("SELECT * FROM posts")
|
||||
f.Add(strings.Repeat("a", 1000))
|
||||
f.Add("<script>alert('xss')</script>")
|
||||
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
if len(input) > 1000 {
|
||||
input = input[:1000]
|
||||
}
|
||||
|
||||
if !utf8.ValidString(input) {
|
||||
return
|
||||
}
|
||||
|
||||
db, err := GetFuzzDB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect to test database: %v", err)
|
||||
}
|
||||
|
||||
db.Exec("DELETE FROM votes")
|
||||
db.Exec("DELETE FROM posts")
|
||||
db.Exec("DELETE FROM users")
|
||||
db.Exec("DELETE FROM account_deletion_requests")
|
||||
db.Exec("DELETE FROM refresh_tokens")
|
||||
|
||||
postRepo := repositories.NewPostRepository(db)
|
||||
sanitizer := repositories.NewSearchSanitizer()
|
||||
|
||||
t.Run("sanitize_and_search", func(t *testing.T) {
|
||||
sanitized, err := sanitizer.SanitizeSearchQuery(input)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !utf8.ValidString(sanitized) {
|
||||
t.Fatalf("Sanitized query should be valid UTF-8: %q", sanitized)
|
||||
}
|
||||
|
||||
posts, searchErr := postRepo.Search(sanitized, 1, 10)
|
||||
if searchErr != nil {
|
||||
if strings.Contains(searchErr.Error(), "panic") {
|
||||
t.Fatalf("Search should not panic: %v", searchErr)
|
||||
}
|
||||
} else {
|
||||
if posts != nil {
|
||||
_ = len(posts)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validate_search_query", func(t *testing.T) {
|
||||
err := sanitizer.ValidateSearchQuery(input)
|
||||
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "panic") {
|
||||
t.Fatalf("ValidateSearchQuery should not panic: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzPostRepository(f *testing.F) {
|
||||
f.Add("test title")
|
||||
f.Add("")
|
||||
f.Add("<script>alert('xss')</script>")
|
||||
f.Add("https://example.com")
|
||||
f.Add(strings.Repeat("a", 500))
|
||||
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
if len(input) > 500 {
|
||||
input = input[:500]
|
||||
}
|
||||
|
||||
if !utf8.ValidString(input) {
|
||||
return
|
||||
}
|
||||
|
||||
db, err := GetFuzzDB()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect to test database: %v", err)
|
||||
}
|
||||
|
||||
db.Exec("DELETE FROM votes")
|
||||
db.Exec("DELETE FROM posts")
|
||||
db.Exec("DELETE FROM users")
|
||||
db.Exec("DELETE FROM account_deletion_requests")
|
||||
db.Exec("DELETE FROM refresh_tokens")
|
||||
|
||||
postRepo := repositories.NewPostRepository(db)
|
||||
|
||||
var userID uint
|
||||
result := db.Exec(`
|
||||
INSERT INTO users (username, email, password, email_verified, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`, "fuzz_test_user", "fuzz@example.com", "hashedpassword", true)
|
||||
if result.Error != nil {
|
||||
t.Fatalf("Failed to create test user: %v", result.Error)
|
||||
}
|
||||
|
||||
var createdUser struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
}
|
||||
db.Raw("SELECT id FROM users WHERE username = ?", "fuzz_test_user").Scan(&createdUser)
|
||||
userID = createdUser.ID
|
||||
|
||||
t.Run("create_and_get_post", func(t *testing.T) {
|
||||
title := input[:min(len(input), 200)]
|
||||
url := "https://example.com/" + input[:min(len(input), 50)]
|
||||
content := input[:min(len(input), 1000)]
|
||||
|
||||
result := db.Exec(`
|
||||
INSERT INTO posts (title, url, content, author_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`, title, url, content, userID)
|
||||
if result.Error != nil {
|
||||
if strings.Contains(result.Error.Error(), "panic") {
|
||||
t.Fatalf("Create should not panic: %v", result.Error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var postID uint
|
||||
var createdPost struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
}
|
||||
db.Raw("SELECT id FROM posts WHERE author_id = ? ORDER BY id DESC LIMIT 1", userID).Scan(&createdPost)
|
||||
postID = createdPost.ID
|
||||
|
||||
if postID == 0 {
|
||||
t.Fatal("Created post should have an ID")
|
||||
}
|
||||
|
||||
retrieved, getErr := postRepo.GetByID(postID)
|
||||
if getErr != nil {
|
||||
t.Fatalf("GetByID should succeed for created post: %v", getErr)
|
||||
}
|
||||
|
||||
if retrieved == nil {
|
||||
t.Fatal("GetByID should return a post")
|
||||
}
|
||||
|
||||
if retrieved.ID != postID {
|
||||
t.Fatalf("Expected post ID %d, got %d", postID, retrieved.ID)
|
||||
}
|
||||
|
||||
posts, listErr := postRepo.GetAll(10, 0)
|
||||
if listErr != nil {
|
||||
t.Fatalf("GetAll should not error: %v", listErr)
|
||||
}
|
||||
|
||||
if posts == nil {
|
||||
t.Fatal("GetAll should return a slice")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, p := range posts {
|
||||
if p.ID == postID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(posts) > 0 {
|
||||
t.Logf("Created post not found in list (this may be acceptable depending on pagination)")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"goyco/internal/config"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/version"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type APIHandler struct {
|
||||
config *config.Config
|
||||
postRepo repositories.PostRepository
|
||||
userRepo repositories.UserRepository
|
||||
voteService *services.VoteService
|
||||
dbMonitor middleware.DBMonitor
|
||||
healthChecker *middleware.DatabaseHealthChecker
|
||||
metricsCollector *middleware.MetricsCollector
|
||||
}
|
||||
|
||||
func NewAPIHandler(config *config.Config, postRepo repositories.PostRepository, userRepo repositories.UserRepository, voteService *services.VoteService) *APIHandler {
|
||||
return &APIHandler{
|
||||
config: config,
|
||||
postRepo: postRepo,
|
||||
userRepo: userRepo,
|
||||
voteService: voteService,
|
||||
}
|
||||
}
|
||||
|
||||
func NewAPIHandlerWithMonitoring(config *config.Config, postRepo repositories.PostRepository, userRepo repositories.UserRepository, voteService *services.VoteService, db *gorm.DB, dbMonitor middleware.DBMonitor) *APIHandler {
|
||||
if db == nil {
|
||||
return NewAPIHandler(config, postRepo, userRepo, voteService)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return NewAPIHandler(config, postRepo, userRepo, voteService)
|
||||
}
|
||||
|
||||
healthChecker := middleware.NewDatabaseHealthChecker(sqlDB, dbMonitor)
|
||||
metricsCollector := middleware.NewMetricsCollector(dbMonitor)
|
||||
|
||||
return &APIHandler{
|
||||
config: config,
|
||||
postRepo: postRepo,
|
||||
userRepo: userRepo,
|
||||
voteService: voteService,
|
||||
dbMonitor: dbMonitor,
|
||||
healthChecker: healthChecker,
|
||||
metricsCollector: metricsCollector,
|
||||
}
|
||||
}
|
||||
|
||||
type APIInfo = CommonResponse
|
||||
|
||||
func (h *APIHandler) GetAPIInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
apiInfo := map[string]any{
|
||||
"name": fmt.Sprintf("%s API", h.config.App.Title),
|
||||
"version": version.Version,
|
||||
"description": "Y Combinator-style news board API",
|
||||
"endpoints": map[string]any{
|
||||
"authentication": map[string]any{
|
||||
"POST /api/auth/register": "Register new user",
|
||||
"POST /api/auth/login": "Login user",
|
||||
"GET /api/auth/confirm": "Confirm email address",
|
||||
"POST /api/auth/resend-verification": "Resend verification email",
|
||||
"POST /api/auth/forgot-password": "Request password reset",
|
||||
"POST /api/auth/reset-password": "Reset password",
|
||||
"POST /api/auth/account/confirm": "Confirm account deletion",
|
||||
"GET /api/auth/me": "Get current user profile",
|
||||
"POST /api/auth/logout": "Logout user",
|
||||
"PUT /api/auth/email": "Update email address",
|
||||
"PUT /api/auth/username": "Update username",
|
||||
"PUT /api/auth/password": "Update password",
|
||||
"DELETE /api/auth/account": "Request account deletion",
|
||||
},
|
||||
"posts": map[string]any{
|
||||
"GET /api/posts": "List all posts",
|
||||
"GET /api/posts/search": "Search posts",
|
||||
"GET /api/posts/title": "Fetch title from URL",
|
||||
"GET /api/posts/{id}": "Get specific post",
|
||||
"POST /api/posts": "Create new post",
|
||||
"PUT /api/posts/{id}": "Update post",
|
||||
"DELETE /api/posts/{id}": "Delete post",
|
||||
},
|
||||
"votes": map[string]any{
|
||||
"POST /api/posts/{id}/vote": "Cast a vote",
|
||||
"DELETE /api/posts/{id}/vote": "Remove vote",
|
||||
"GET /api/posts/{id}/vote": "Get user's vote",
|
||||
"GET /api/posts/{id}/votes": "Get all votes for post",
|
||||
},
|
||||
"users": map[string]any{
|
||||
"GET /api/users": "List all users",
|
||||
"POST /api/users": "Create new user",
|
||||
"GET /api/users/{id}": "Get specific user",
|
||||
"GET /api/users/{id}/posts": "Get user's posts",
|
||||
},
|
||||
"system": map[string]any{
|
||||
"GET /health": "Health check",
|
||||
"GET /metrics": "Service metrics",
|
||||
},
|
||||
},
|
||||
"authentication": map[string]any{
|
||||
"type": "Bearer Token (JWT)",
|
||||
"note": "Include Authorization header with 'Bearer <token>' for protected endpoints",
|
||||
},
|
||||
"response_format": map[string]any{
|
||||
"success": "boolean",
|
||||
"message": "string",
|
||||
"data": "object or array",
|
||||
"error": "string (on error)",
|
||||
},
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "API information retrieved successfully", apiInfo)
|
||||
}
|
||||
|
||||
func (h *APIHandler) GetHealth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if h.healthChecker != nil {
|
||||
health := h.healthChecker.CheckHealth()
|
||||
health["version"] = version.Version
|
||||
SendSuccessResponse(w, "Health check successful", health)
|
||||
return
|
||||
}
|
||||
|
||||
currentTimestamp := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
health := map[string]any{
|
||||
"status": "healthy",
|
||||
"timestamp": currentTimestamp,
|
||||
"version": version.Version,
|
||||
"services": map[string]any{
|
||||
"database": "connected",
|
||||
"api": "running",
|
||||
},
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Health check successful", health)
|
||||
}
|
||||
|
||||
func (h *APIHandler) GetMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
postCount, err := h.postRepo.Count()
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Failed to get post count", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
userCount, err := h.userRepo.Count()
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Failed to get user count", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
totalVoteCount, _, err := h.voteService.GetVoteStatistics()
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Failed to get vote statistics", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
topPosts, err := h.postRepo.GetTopPosts(5)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Failed to get top posts", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var avgVotesPerPost float64
|
||||
if postCount > 0 {
|
||||
avgVotesPerPost = float64(totalVoteCount) / float64(postCount)
|
||||
}
|
||||
|
||||
var totalScore int
|
||||
for _, post := range topPosts {
|
||||
totalScore += post.Score
|
||||
}
|
||||
|
||||
var avgScore float64
|
||||
if len(topPosts) > 0 {
|
||||
avgScore = float64(totalScore) / float64(len(topPosts))
|
||||
}
|
||||
|
||||
metrics := map[string]any{
|
||||
"posts": map[string]any{
|
||||
"total_count": postCount,
|
||||
"top_posts_count": len(topPosts),
|
||||
"total_score": totalScore,
|
||||
"average_score": avgScore,
|
||||
},
|
||||
"users": map[string]any{
|
||||
"total_count": userCount,
|
||||
},
|
||||
"votes": map[string]any{
|
||||
"total_count": totalVoteCount,
|
||||
"average_per_post": avgVotesPerPost,
|
||||
"note": "All votes are counted together",
|
||||
},
|
||||
"system": map[string]any{
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"version": version.Version,
|
||||
},
|
||||
}
|
||||
|
||||
if h.metricsCollector != nil {
|
||||
performanceMetrics := h.metricsCollector.GetMetrics()
|
||||
metrics["database"] = map[string]any{
|
||||
"total_queries": performanceMetrics.DBStats.TotalQueries,
|
||||
"slow_queries": performanceMetrics.DBStats.SlowQueries,
|
||||
"average_duration": performanceMetrics.DBStats.AverageDuration.String(),
|
||||
"max_duration": performanceMetrics.DBStats.MaxDuration.String(),
|
||||
"error_count": performanceMetrics.DBStats.ErrorCount,
|
||||
"last_query_time": performanceMetrics.DBStats.LastQueryTime.Format(time.RFC3339),
|
||||
}
|
||||
metrics["performance"] = map[string]any{
|
||||
"request_count": performanceMetrics.RequestCount,
|
||||
"average_response": performanceMetrics.AverageResponse.String(),
|
||||
"max_response": performanceMetrics.MaxResponse.String(),
|
||||
"error_count": performanceMetrics.ErrorCount,
|
||||
}
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Metrics retrieved successfully", metrics)
|
||||
}
|
||||
|
||||
func (h *APIHandler) MountRoutes(r chi.Router, config RouteModuleConfig) {
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestAPIHandlerGetAPIInfo(t *testing.T) {
|
||||
mockPostRepo := testutils.NewPostRepositoryStub()
|
||||
mockUserRepo := testutils.NewUserRepositoryStub()
|
||||
handler := newAPIHandlerForTest(mockPostRepo, mockUserRepo)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api", nil)
|
||||
|
||||
handler.GetAPIInfo(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
var resp APIInfo
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !resp.Success || resp.Message == "" {
|
||||
t.Fatalf("expected success response, got %+v", resp)
|
||||
}
|
||||
|
||||
data, ok := resp.Data.(map[string]any)
|
||||
if !ok || data["name"] != fmt.Sprintf("%s API", testutils.AppTestConfig.App.Title) {
|
||||
t.Fatalf("unexpected data payload: %#v", resp.Data)
|
||||
}
|
||||
|
||||
endpoints, ok := data["endpoints"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected endpoints map, got %#v", data["endpoints"])
|
||||
}
|
||||
|
||||
authEndpoints := endpoints["authentication"].(map[string]any)
|
||||
for _, route := range []string{
|
||||
"POST /api/auth/resend-verification",
|
||||
"POST /api/auth/account/confirm",
|
||||
} {
|
||||
if _, found := authEndpoints[route]; !found {
|
||||
t.Fatalf("expected authentication catalogue to include %s", route)
|
||||
}
|
||||
}
|
||||
|
||||
systemEndpoints := endpoints["system"].(map[string]any)
|
||||
if _, found := systemEndpoints["GET /metrics"]; !found {
|
||||
t.Fatalf("expected system catalogue to include GET /metrics")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIHandlerGetHealth(t *testing.T) {
|
||||
mockPostRepo := testutils.NewPostRepositoryStub()
|
||||
mockUserRepo := testutils.NewUserRepositoryStub()
|
||||
handler := newAPIHandlerForTest(mockPostRepo, mockUserRepo)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
|
||||
handler.GetHealth(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
var resp APIInfo
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
|
||||
if !resp.Success || resp.Message == "" {
|
||||
t.Fatalf("expected success message, got %+v", resp)
|
||||
}
|
||||
|
||||
data := resp.Data.(map[string]any)
|
||||
if data["status"] != "healthy" {
|
||||
t.Fatalf("expected health status, got %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIHandlerGetMetrics(t *testing.T) {
|
||||
mockPostRepo := testutils.NewPostRepositoryStub()
|
||||
mockPostRepo.CountFn = func() (int64, error) { return 10, nil }
|
||||
mockPostRepo.GetTopPostsFn = func(limit int) ([]database.Post, error) {
|
||||
return []database.Post{
|
||||
{ID: 1, Score: 100},
|
||||
{ID: 2, Score: 50},
|
||||
{ID: 3, Score: 25},
|
||||
}, nil
|
||||
}
|
||||
|
||||
mockUserRepo := testutils.NewUserRepositoryStub()
|
||||
mockUserRepo.CountFn = func() (int64, error) { return 5, nil }
|
||||
|
||||
handler := newAPIHandlerForTest(mockPostRepo, mockUserRepo)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
|
||||
handler.GetMetrics(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
var resp APIInfo
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
|
||||
if !resp.Success || resp.Message == "" {
|
||||
t.Fatalf("expected success response, got %+v", resp)
|
||||
}
|
||||
|
||||
data, ok := resp.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected metrics data map, got %T", resp.Data)
|
||||
}
|
||||
|
||||
if data["posts"] == nil {
|
||||
t.Fatalf("expected metrics payload to include posts")
|
||||
}
|
||||
if data["users"] == nil {
|
||||
t.Fatalf("expected metrics payload to include users")
|
||||
}
|
||||
if data["votes"] == nil {
|
||||
t.Fatalf("expected metrics payload to include votes")
|
||||
}
|
||||
if data["system"] == nil {
|
||||
t.Fatalf("expected metrics payload to include system")
|
||||
}
|
||||
|
||||
posts, ok := data["posts"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected posts to be a map, got %T", data["posts"])
|
||||
}
|
||||
if posts["total_count"] != float64(10) {
|
||||
t.Fatalf("expected posts total_count to be 10, got %v", posts["total_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func newAPIHandlerForTest(postRepo repositories.PostRepository, userRepo repositories.UserRepository) *APIHandler {
|
||||
voteRepo := testutils.NewMockVoteRepository()
|
||||
voteService := services.NewVoteService(voteRepo, postRepo, nil)
|
||||
return NewAPIHandler(testutils.AppTestConfig, postRepo, userRepo, voteService)
|
||||
}
|
||||
|
||||
func TestAPIHandlerGetMetricsErrorHandling(t *testing.T) {
|
||||
mockPostRepo := testutils.NewPostRepositoryStub()
|
||||
mockPostRepo.CountFn = func() (int64, error) { return 0, errors.New("database error") }
|
||||
|
||||
mockUserRepo := testutils.NewUserRepositoryStub()
|
||||
handler := newAPIHandlerForTest(mockPostRepo, mockUserRepo)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
|
||||
handler.GetMetrics(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusInternalServerError)
|
||||
|
||||
var resp APIInfo
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
|
||||
if resp.Success {
|
||||
t.Fatalf("expected error response, got %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIHandlerGetMetricsWithDatabaseMonitoring(t *testing.T) {
|
||||
mockPostRepo := testutils.NewPostRepositoryStub()
|
||||
mockPostRepo.CountFn = func() (int64, error) { return 10, nil }
|
||||
mockPostRepo.GetTopPostsFn = func(limit int) ([]database.Post, error) {
|
||||
return []database.Post{
|
||||
{ID: 1, Score: 100},
|
||||
{ID: 2, Score: 50},
|
||||
}, nil
|
||||
}
|
||||
|
||||
mockUserRepo := testutils.NewUserRepositoryStub()
|
||||
mockUserRepo.CountFn = func() (int64, error) { return 5, nil }
|
||||
|
||||
voteRepo := testutils.NewMockVoteRepository()
|
||||
voteService := services.NewVoteService(voteRepo, mockPostRepo, nil)
|
||||
|
||||
handler := NewAPIHandler(testutils.AppTestConfig, mockPostRepo, mockUserRepo, voteService)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
|
||||
handler.GetMetrics(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
var resp APIInfo
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
t.Fatalf("expected success response, got %+v", resp)
|
||||
}
|
||||
|
||||
data, ok := resp.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected metrics data map, got %T", resp.Data)
|
||||
}
|
||||
|
||||
expectedSections := []string{"posts", "users", "votes", "system"}
|
||||
for _, section := range expectedSections {
|
||||
if data[section] == nil {
|
||||
t.Fatalf("expected metrics payload to include %s", section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAPIHandlerWithMonitoring(t *testing.T) {
|
||||
mockPostRepo := testutils.NewPostRepositoryStub()
|
||||
mockUserRepo := testutils.NewUserRepositoryStub()
|
||||
voteRepo := testutils.NewMockVoteRepository()
|
||||
voteService := services.NewVoteService(voteRepo, mockPostRepo, nil)
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
|
||||
db := testutils.NewTestDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
}()
|
||||
|
||||
handler := NewAPIHandlerWithMonitoring(testutils.AppTestConfig, mockPostRepo, mockUserRepo, voteService, db, monitor)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("Expected handler to be created")
|
||||
}
|
||||
|
||||
if handler.dbMonitor == nil {
|
||||
t.Error("Expected dbMonitor to be set")
|
||||
}
|
||||
|
||||
if handler.healthChecker == nil {
|
||||
t.Error("Expected healthChecker to be set")
|
||||
}
|
||||
|
||||
if handler.metricsCollector == nil {
|
||||
t.Error("Expected metricsCollector to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAPIHandlerWithMonitoring_NilDB(t *testing.T) {
|
||||
mockPostRepo := testutils.NewPostRepositoryStub()
|
||||
mockUserRepo := testutils.NewUserRepositoryStub()
|
||||
voteRepo := testutils.NewMockVoteRepository()
|
||||
voteService := services.NewVoteService(voteRepo, mockPostRepo, nil)
|
||||
|
||||
handler := NewAPIHandlerWithMonitoring(testutils.AppTestConfig, mockPostRepo, mockUserRepo, voteService, nil, nil)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("Expected handler to be created")
|
||||
}
|
||||
|
||||
if handler.dbMonitor != nil {
|
||||
t.Error("Expected dbMonitor to be nil when db is nil")
|
||||
}
|
||||
|
||||
if handler.healthChecker != nil {
|
||||
t.Error("Expected healthChecker to be nil when db is nil")
|
||||
}
|
||||
|
||||
if handler.metricsCollector != nil {
|
||||
t.Error("Expected metricsCollector to be nil when db is nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/dto"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/security"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/validation"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type AuthServiceInterface interface {
|
||||
Login(username, password string) (*services.AuthResult, error)
|
||||
Register(username, email, password string) (*services.RegistrationResult, error)
|
||||
ConfirmEmail(token string) (*database.User, error)
|
||||
ResendVerificationEmail(email string) error
|
||||
RequestPasswordReset(usernameOrEmail string) error
|
||||
ResetPassword(token, newPassword string) error
|
||||
UpdateEmail(userID uint, email string) (*database.User, error)
|
||||
UpdateUsername(userID uint, username string) (*database.User, error)
|
||||
UpdatePassword(userID uint, currentPassword, newPassword string) (*database.User, error)
|
||||
RequestAccountDeletion(userID uint) error
|
||||
ConfirmAccountDeletionWithPosts(token string, deletePosts bool) error
|
||||
RefreshAccessToken(refreshToken string) (*services.AuthResult, error)
|
||||
RevokeRefreshToken(refreshToken string) error
|
||||
RevokeAllUserTokens(userID uint) error
|
||||
InvalidateAllSessions(userID uint) error
|
||||
GetAdminEmail() string
|
||||
VerifyToken(tokenString string) (uint, error)
|
||||
GetUserIDFromDeletionToken(token string) (uint, error)
|
||||
UserHasPosts(userID uint) (bool, int64, error)
|
||||
}
|
||||
|
||||
type AuthHandler struct {
|
||||
authService AuthServiceInterface
|
||||
userRepo repositories.UserRepository
|
||||
}
|
||||
|
||||
type AuthResponse = CommonResponse
|
||||
|
||||
type AuthTokensResponse struct {
|
||||
Success bool `json:"success" example:"true"`
|
||||
Message string `json:"message" example:"Authentication successful"`
|
||||
Data AuthTokensDetail `json:"data"`
|
||||
}
|
||||
|
||||
type AuthTokensDetail struct {
|
||||
AccessToken string `json:"access_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
|
||||
RefreshToken string `json:"refresh_token" example:"f94d4ddc7d9b4fcb9d3a2c44c400b780"`
|
||||
User AuthUserSummary `json:"user"`
|
||||
}
|
||||
|
||||
type AuthUserSummary struct {
|
||||
ID uint `json:"id" example:"42"`
|
||||
Username string `json:"username" example:"janedoe"`
|
||||
Email string `json:"email" example:"jane@example.com"`
|
||||
EmailVerified bool `json:"email_verified" example:"true"`
|
||||
Locked bool `json:"locked" example:"false"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type CreatePostRequest struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ResendVerificationRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type ForgotPasswordRequest struct {
|
||||
UsernameOrEmail string `json:"username_or_email"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type UpdateEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type UpdateUsernameRequest struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type UpdatePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type ConfirmAccountDeletionRequest struct {
|
||||
Token string `json:"token"`
|
||||
DeletePosts bool `json:"delete_posts"`
|
||||
}
|
||||
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." binding:"required"`
|
||||
}
|
||||
|
||||
type RevokeTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." binding:"required"`
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService AuthServiceInterface, userRepo repositories.UserRepository) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Login user
|
||||
// @Description Authenticate user with username and password
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body LoginRequest true "Login credentials"
|
||||
// @Success 200 {object} AuthTokensResponse "Authentication successful"
|
||||
// @Failure 400 {object} AuthResponse "Invalid request data or validation failed"
|
||||
// @Failure 401 {object} AuthResponse "Invalid credentials"
|
||||
// @Failure 403 {object} AuthResponse "Account is locked"
|
||||
// @Failure 500 {object} AuthResponse "Internal server error"
|
||||
// @Router /auth/login [post]
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
username := security.SanitizeUsername(req.Username)
|
||||
password := strings.TrimSpace(req.Password)
|
||||
|
||||
if username == "" || password == "" {
|
||||
SendErrorResponse(w, "Username and password are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidatePassword(password); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.authService.Login(username, password)
|
||||
if !HandleServiceError(w, err, "Authentication failed", http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Authentication successful", result)
|
||||
}
|
||||
|
||||
// @Summary Register a new user
|
||||
// @Description Register a new user with username, email and password
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RegisterRequest true "Registration data"
|
||||
// @Success 201 {object} AuthResponse "Registration successful"
|
||||
// @Failure 400 {object} AuthResponse "Invalid request data or validation failed"
|
||||
// @Failure 409 {object} AuthResponse "Username or email already exists"
|
||||
// @Failure 500 {object} AuthResponse "Internal server error"
|
||||
// @Router /auth/register [post]
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(req.Username)
|
||||
email := strings.TrimSpace(req.Email)
|
||||
password := strings.TrimSpace(req.Password)
|
||||
|
||||
if username == "" || email == "" || password == "" {
|
||||
SendErrorResponse(w, "Username, email, and password are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username = security.SanitizeUsername(username)
|
||||
if err := validation.ValidateUsername(username); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidateEmail(email); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidatePassword(password); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.authService.Register(username, email, password)
|
||||
if err != nil {
|
||||
var validationErr *validation.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !HandleServiceError(w, err, "Registration failed", http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
userData := map[string]any{
|
||||
"id": result.User.ID,
|
||||
"username": result.User.Username,
|
||||
"email": result.User.Email,
|
||||
"email_verified": result.User.EmailVerified,
|
||||
"created_at": result.User.CreatedAt,
|
||||
"updated_at": result.User.UpdatedAt,
|
||||
"deleted_at": result.User.DeletedAt,
|
||||
}
|
||||
|
||||
responseData := map[string]any{
|
||||
"user": userData,
|
||||
"verification_sent": result.VerificationSent,
|
||||
}
|
||||
|
||||
SendCreatedResponse(w, "Registration successful. Check your email to confirm your account.", responseData)
|
||||
}
|
||||
|
||||
// @Summary Confirm email address
|
||||
// @Description Confirm user email with verification token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token query string true "Email verification token"
|
||||
// @Success 200 {object} AuthResponse "Email confirmed successfully"
|
||||
// @Failure 400 {object} AuthResponse "Invalid or missing token"
|
||||
// @Failure 500 {object} AuthResponse "Internal server error"
|
||||
// @Router /auth/confirm [get]
|
||||
func (h *AuthHandler) ConfirmEmail(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
if token == "" {
|
||||
SendErrorResponse(w, "Verification token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.ConfirmEmail(token)
|
||||
if !HandleServiceError(w, err, "Unable to verify email", http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
|
||||
userDTO := dto.ToUserDTO(user)
|
||||
SendSuccessResponse(w, "Email confirmed successfully", map[string]any{
|
||||
"user": userDTO,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Resend verification email
|
||||
// @Description Send a new verification email to the provided address
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ResendVerificationRequest true "Email address"
|
||||
// @Success 200 {object} AuthResponse
|
||||
// @Failure 400 {object} AuthResponse
|
||||
// @Failure 404 {object} AuthResponse
|
||||
// @Failure 409 {object} AuthResponse
|
||||
// @Failure 429 {object} AuthResponse
|
||||
// @Failure 503 {object} AuthResponse
|
||||
// @Failure 500 {object} AuthResponse
|
||||
// @Router /auth/resend-verification [post]
|
||||
func (h *AuthHandler) ResendVerificationEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(req.Email)
|
||||
if email == "" {
|
||||
SendErrorResponse(w, "Email address is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.ResendVerificationEmail(email)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrInvalidCredentials):
|
||||
SendErrorResponse(w, "No account found with this email address", http.StatusNotFound)
|
||||
case errors.Is(err, services.ErrInvalidEmail):
|
||||
SendErrorResponse(w, "Invalid email address format", http.StatusBadRequest)
|
||||
case errors.Is(err, services.ErrEmailSenderUnavailable):
|
||||
SendErrorResponse(w, "We couldn't send the verification email. Try again later.", http.StatusServiceUnavailable)
|
||||
case err.Error() == "email already verified":
|
||||
SendErrorResponse(w, "This email address is already verified", http.StatusConflict)
|
||||
case err.Error() == "verification email sent recently, please wait before requesting another":
|
||||
SendErrorResponse(w, "Please wait 5 minutes before requesting another verification email", http.StatusTooManyRequests)
|
||||
default:
|
||||
SendErrorResponse(w, "Unable to resend verification email", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Verification email sent successfully", map[string]any{
|
||||
"message": "Check your inbox for the verification link",
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Get current user profile
|
||||
// @Description Retrieve the authenticated user's profile information
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} AuthResponse "User profile retrieved successfully"
|
||||
// @Failure 401 {object} AuthResponse "Authentication required"
|
||||
// @Failure 404 {object} AuthResponse "User not found"
|
||||
// @Router /auth/me [get]
|
||||
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userRepo.GetByID(userID)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
userDTO := dto.ToUserDTO(user)
|
||||
SendSuccessResponse(w, "User profile fetched", userDTO)
|
||||
}
|
||||
|
||||
// @Summary Request a password reset
|
||||
// @Description Send a password reset email using a username or email
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ForgotPasswordRequest true "Username or email"
|
||||
// @Success 200 {object} AuthResponse "Password reset email sent if account exists"
|
||||
// @Failure 400 {object} AuthResponse "Invalid request data"
|
||||
// @Router /auth/forgot-password [post]
|
||||
func (h *AuthHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
UsernameOrEmail string `json:"username_or_email"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
usernameOrEmail := strings.TrimSpace(req.UsernameOrEmail)
|
||||
if usernameOrEmail == "" {
|
||||
SendErrorResponse(w, "Username or email is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.RequestPasswordReset(usernameOrEmail); err != nil {
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "If an account with that username or email exists, we've sent a password reset link.", nil)
|
||||
}
|
||||
|
||||
// @Summary Reset password
|
||||
// @Description Reset a user's password using a reset token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ResetPasswordRequest true "Password reset data"
|
||||
// @Success 200 {object} AuthResponse "Password reset successfully"
|
||||
// @Failure 400 {object} AuthResponse "Invalid or expired token, or validation failed"
|
||||
// @Failure 500 {object} AuthResponse "Internal server error"
|
||||
// @Router /auth/reset-password [post]
|
||||
func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(req.Token)
|
||||
newPassword := strings.TrimSpace(req.NewPassword)
|
||||
|
||||
if token == "" {
|
||||
SendErrorResponse(w, "Reset token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword == "" {
|
||||
SendErrorResponse(w, "New password is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(newPassword) < 8 {
|
||||
SendErrorResponse(w, "Password must be at least 8 characters long", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.ResetPassword(token, newPassword); err != nil {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "expired"):
|
||||
SendErrorResponse(w, "The reset link has expired. Please request a new one.", http.StatusBadRequest)
|
||||
case strings.Contains(err.Error(), "invalid"):
|
||||
SendErrorResponse(w, "The reset link is invalid. Please request a new one.", http.StatusBadRequest)
|
||||
default:
|
||||
SendErrorResponse(w, "Unable to reset password. Please try again later.", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Password reset successfully. You can now sign in with your new password.", nil)
|
||||
}
|
||||
|
||||
// @Summary Update email address
|
||||
// @Description Update the authenticated user's email address
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body UpdateEmailRequest true "New email address"
|
||||
// @Success 200 {object} AuthResponse
|
||||
// @Failure 400 {object} AuthResponse
|
||||
// @Failure 401 {object} AuthResponse
|
||||
// @Failure 409 {object} AuthResponse
|
||||
// @Failure 503 {object} AuthResponse
|
||||
// @Failure 500 {object} AuthResponse
|
||||
// @Router /auth/email [put]
|
||||
func (h *AuthHandler) UpdateEmail(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(req.Email)
|
||||
if err := validation.ValidateEmail(email); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.UpdateEmail(userID, email)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrEmailTaken):
|
||||
SendErrorResponse(w, "That email is already in use. Choose another one.", http.StatusConflict)
|
||||
case errors.Is(err, services.ErrEmailSenderUnavailable):
|
||||
SendErrorResponse(w, "We couldn't send the confirmation email. Try again later.", http.StatusServiceUnavailable)
|
||||
case errors.Is(err, services.ErrInvalidEmail):
|
||||
SendErrorResponse(w, "Invalid email address", http.StatusBadRequest)
|
||||
default:
|
||||
SendErrorResponse(w, "We couldn't update your email right now.", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
userDTO := dto.ToUserDTO(user)
|
||||
SendSuccessResponse(w, "Email updated. Check your inbox to confirm the new address.", map[string]any{
|
||||
"user": userDTO,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Update username
|
||||
// @Description Update the authenticated user's username
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body UpdateUsernameRequest true "New username"
|
||||
// @Success 200 {object} AuthResponse
|
||||
// @Failure 400 {object} AuthResponse
|
||||
// @Failure 401 {object} AuthResponse
|
||||
// @Failure 409 {object} AuthResponse
|
||||
// @Failure 500 {object} AuthResponse
|
||||
// @Router /auth/username [put]
|
||||
func (h *AuthHandler) UpdateUsername(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(req.Username)
|
||||
if err := validation.ValidateUsername(username); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.UpdateUsername(userID, username)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrUsernameTaken):
|
||||
SendErrorResponse(w, "That username is already taken. Try another one.", http.StatusConflict)
|
||||
default:
|
||||
SendErrorResponse(w, "We couldn't update your username right now.", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
userDTO := dto.ToUserDTO(user)
|
||||
SendSuccessResponse(w, "Username updated successfully.", map[string]any{
|
||||
"user": userDTO,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Update password
|
||||
// @Description Update the authenticated user's password
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body UpdatePasswordRequest true "Password update data"
|
||||
// @Success 200 {object} AuthResponse
|
||||
// @Failure 400 {object} AuthResponse
|
||||
// @Failure 401 {object} AuthResponse
|
||||
// @Failure 500 {object} AuthResponse
|
||||
// @Router /auth/password [put]
|
||||
func (h *AuthHandler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
currentPassword := strings.TrimSpace(req.CurrentPassword)
|
||||
newPassword := strings.TrimSpace(req.NewPassword)
|
||||
|
||||
if currentPassword == "" {
|
||||
SendErrorResponse(w, "Current password is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidatePassword(newPassword); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.UpdatePassword(userID, currentPassword, newPassword)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "current password is incorrect") {
|
||||
SendErrorResponse(w, "Current password is incorrect", http.StatusBadRequest)
|
||||
} else {
|
||||
SendErrorResponse(w, "We couldn't update your password right now.", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
userDTO := dto.ToUserDTO(user)
|
||||
SendSuccessResponse(w, "Password updated successfully.", map[string]any{
|
||||
"user": userDTO,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Request account deletion
|
||||
// @Description Initiate the deletion process for the authenticated user's account
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} AuthResponse "Deletion email sent"
|
||||
// @Failure 401 {object} AuthResponse "Authentication required"
|
||||
// @Failure 503 {object} AuthResponse "Email delivery unavailable"
|
||||
// @Failure 500 {object} AuthResponse "Internal server error"
|
||||
// @Router /auth/account [delete]
|
||||
func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.RequestAccountDeletion(userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrEmailSenderUnavailable) {
|
||||
SendErrorResponse(w, "Account deletion isn't available right now because email delivery is disabled.", http.StatusServiceUnavailable)
|
||||
} else {
|
||||
SendErrorResponse(w, "We couldn't start the deletion process right now.", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Check your inbox for a confirmation link to finish deleting your account.", nil)
|
||||
}
|
||||
|
||||
// @Summary Confirm account deletion
|
||||
// @Description Confirm account deletion using the provided token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ConfirmAccountDeletionRequest true "Account deletion data"
|
||||
// @Success 200 {object} AuthResponse "Account deleted successfully"
|
||||
// @Failure 400 {object} AuthResponse "Invalid or expired token"
|
||||
// @Failure 503 {object} AuthResponse "Email delivery unavailable"
|
||||
// @Failure 500 {object} AuthResponse "Internal server error"
|
||||
// @Router /auth/account/confirm [post]
|
||||
func (h *AuthHandler) ConfirmAccountDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
DeletePosts bool `json:"delete_posts"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(req.Token)
|
||||
if token == "" {
|
||||
SendErrorResponse(w, "Deletion token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.ConfirmAccountDeletionWithPosts(token, req.DeletePosts); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrInvalidDeletionToken):
|
||||
SendErrorResponse(w, "This deletion link is invalid or has expired.", http.StatusBadRequest)
|
||||
case errors.Is(err, services.ErrEmailSenderUnavailable):
|
||||
SendErrorResponse(w, "Account deletion isn't available right now because email delivery is disabled.", http.StatusServiceUnavailable)
|
||||
case errors.Is(err, services.ErrDeletionEmailFailed):
|
||||
SendSuccessResponse(w, "Your account has been deleted, but we couldn't send the confirmation email.", map[string]any{
|
||||
"posts_deleted": req.DeletePosts,
|
||||
})
|
||||
default:
|
||||
SendErrorResponse(w, "We couldn't confirm the deletion right now.", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Your account has been deleted.", map[string]any{
|
||||
"posts_deleted": req.DeletePosts,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Logout user
|
||||
// @Description Logout the authenticated user and invalidate their session
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} AuthResponse "Logged out successfully"
|
||||
// @Failure 401 {object} AuthResponse "Authentication required"
|
||||
// @Router /auth/logout [post]
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
SendSuccessResponse(w, "Logged out successfully", nil)
|
||||
}
|
||||
|
||||
// @Summary Refresh access token
|
||||
// @Description Use a refresh token to get a new access token. This endpoint allows clients to obtain a new access token using a valid refresh token without requiring user credentials.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RefreshTokenRequest true "Refresh token data"
|
||||
// @Success 200 {object} AuthTokensResponse "Token refreshed successfully"
|
||||
// @Failure 400 {object} AuthResponse "Invalid request body or missing refresh token"
|
||||
// @Failure 401 {object} AuthResponse "Invalid or expired refresh token"
|
||||
// @Failure 403 {object} AuthResponse "Account is locked"
|
||||
// @Failure 500 {object} AuthResponse "Internal server error"
|
||||
// @Router /auth/refresh [post]
|
||||
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req RefreshTokenRequest
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.RefreshToken) == "" {
|
||||
SendErrorResponse(w, "Refresh token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.authService.RefreshAccessToken(req.RefreshToken)
|
||||
if !HandleServiceError(w, err, "Token refresh failed", http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Token refreshed successfully", result)
|
||||
}
|
||||
|
||||
// @Summary Revoke refresh token
|
||||
// @Description Revoke a specific refresh token. This endpoint allows authenticated users to invalidate a specific refresh token, preventing its future use.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body RevokeTokenRequest true "Token revocation data"
|
||||
// @Success 200 {object} AuthResponse "Token revoked successfully"
|
||||
// @Failure 400 {object} AuthResponse "Invalid request body or missing refresh token"
|
||||
// @Failure 401 {object} AuthResponse "Invalid or expired access token"
|
||||
// @Failure 500 {object} AuthResponse "Internal server error"
|
||||
// @Router /auth/revoke [post]
|
||||
func (h *AuthHandler) RevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req RevokeTokenRequest
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.RefreshToken) == "" {
|
||||
SendErrorResponse(w, "Refresh token is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.RevokeRefreshToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Failed to revoke token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Token revoked successfully", nil)
|
||||
}
|
||||
|
||||
// @Summary Revoke all user tokens
|
||||
// @Description Revoke all refresh tokens for the authenticated user. This endpoint allows users to invalidate all their refresh tokens at once, effectively logging them out from all devices.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} AuthResponse "All tokens revoked successfully"
|
||||
// @Failure 401 {object} AuthResponse "Invalid or expired access token"
|
||||
// @Failure 500 {object} AuthResponse "Internal server error"
|
||||
// @Router /auth/revoke-all [post]
|
||||
func (h *AuthHandler) RevokeAllTokens(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.RevokeAllUserTokens(userID)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Failed to revoke tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "All tokens revoked successfully", nil)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) MountRoutes(r chi.Router, config RouteModuleConfig) {
|
||||
if config.GeneralRateLimit != nil {
|
||||
rateLimited := config.GeneralRateLimit(r)
|
||||
rateLimited.Post("/auth/refresh", h.RefreshToken)
|
||||
rateLimited.Get("/auth/confirm", h.ConfirmEmail)
|
||||
rateLimited.Post("/auth/resend-verification", h.ResendVerificationEmail)
|
||||
} else {
|
||||
r.Post("/auth/refresh", h.RefreshToken)
|
||||
r.Get("/auth/confirm", h.ConfirmEmail)
|
||||
r.Post("/auth/resend-verification", h.ResendVerificationEmail)
|
||||
}
|
||||
|
||||
if config.AuthRateLimit != nil {
|
||||
rateLimited := config.AuthRateLimit(r)
|
||||
rateLimited.Post("/auth/register", h.Register)
|
||||
rateLimited.Post("/auth/login", h.Login)
|
||||
rateLimited.Post("/auth/forgot-password", h.RequestPasswordReset)
|
||||
rateLimited.Post("/auth/reset-password", h.ResetPassword)
|
||||
rateLimited.Post("/auth/account/confirm", h.ConfirmAccountDeletion)
|
||||
} else {
|
||||
r.Post("/auth/register", h.Register)
|
||||
r.Post("/auth/login", h.Login)
|
||||
r.Post("/auth/forgot-password", h.RequestPasswordReset)
|
||||
r.Post("/auth/reset-password", h.ResetPassword)
|
||||
r.Post("/auth/account/confirm", h.ConfirmAccountDeletion)
|
||||
}
|
||||
|
||||
protected := r
|
||||
if config.AuthMiddleware != nil {
|
||||
protected = r.With(config.AuthMiddleware)
|
||||
}
|
||||
if config.GeneralRateLimit != nil {
|
||||
protected = config.GeneralRateLimit(protected)
|
||||
}
|
||||
|
||||
protected.Get("/auth/me", h.Me)
|
||||
protected.Post("/auth/logout", h.Logout)
|
||||
protected.Post("/auth/revoke", h.RevokeToken)
|
||||
protected.Post("/auth/revoke-all", h.RevokeAllTokens)
|
||||
protected.Put("/auth/email", h.UpdateEmail)
|
||||
protected.Put("/auth/username", h.UpdateUsername)
|
||||
protected.Put("/auth/password", h.UpdatePassword)
|
||||
protected.Delete("/auth/account", h.DeleteAccount)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/dto"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/services"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CommonResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PaginationData struct {
|
||||
Count int `json:"count"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type VoteCookieData struct {
|
||||
Type database.VoteType `json:"type"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
func sendResponse(w http.ResponseWriter, statusCode int, success bool, message string, data any, errMsg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := CommonResponse{
|
||||
Success: success,
|
||||
Message: message,
|
||||
Data: data,
|
||||
Error: errMsg,
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func SendSuccessResponse(w http.ResponseWriter, message string, data any) {
|
||||
sendResponse(w, http.StatusOK, true, message, data, "")
|
||||
}
|
||||
|
||||
func SendCreatedResponse(w http.ResponseWriter, message string, data any) {
|
||||
sendResponse(w, http.StatusCreated, true, message, data, "")
|
||||
}
|
||||
|
||||
func SendErrorResponse(w http.ResponseWriter, message string, statusCode int) {
|
||||
sendResponse(w, statusCode, false, "", nil, message)
|
||||
}
|
||||
|
||||
func DecodeJSONRequest(w http.ResponseWriter, r *http.Request, req any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
|
||||
SendErrorResponse(w, "Invalid request body", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func GetClientIP(r *http.Request) string {
|
||||
return middleware.GetSecureClientIP(r)
|
||||
}
|
||||
|
||||
const (
|
||||
CookieMaxAgeDays = 30
|
||||
SecondsPerDay = 86400
|
||||
DefaultPaginationLimit = 20
|
||||
DefaultPaginationOffset = 0
|
||||
)
|
||||
|
||||
func SetVoteCookie(w http.ResponseWriter, r *http.Request, postID uint, voteType database.VoteType) {
|
||||
cookieName := fmt.Sprintf("vote_%d", postID)
|
||||
cookieValue := fmt.Sprintf("%s:%d", voteType, time.Now().Unix())
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: cookieValue,
|
||||
Path: "/",
|
||||
MaxAge: SecondsPerDay * CookieMaxAgeDays,
|
||||
HttpOnly: true,
|
||||
Secure: IsHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
func GetVoteCookie(r *http.Request, postID uint) string {
|
||||
cookieName := fmt.Sprintf("vote_%d", postID)
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
func ClearVoteCookie(w http.ResponseWriter, postID uint) {
|
||||
cookieName := fmt.Sprintf("vote_%d", postID)
|
||||
cookie := &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
func IsHTTPS(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
|
||||
return true
|
||||
}
|
||||
|
||||
if proto := r.Header.Get("X-Forwarded-Ssl"); proto == "on" {
|
||||
return true
|
||||
}
|
||||
|
||||
if proto := r.Header.Get("X-Forwarded-Scheme"); proto == "https" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func SanitizeUser(user *database.User) dto.SanitizedUserDTO {
|
||||
if user == nil {
|
||||
return dto.SanitizedUserDTO{}
|
||||
}
|
||||
return dto.ToSanitizedUserDTO(user)
|
||||
}
|
||||
|
||||
func SanitizeUsers(users []database.User) []dto.SanitizedUserDTO {
|
||||
return dto.ToSanitizedUserDTOs(users)
|
||||
}
|
||||
|
||||
func parsePagination(r *http.Request) (limit, offset int) {
|
||||
limit = DefaultPaginationLimit
|
||||
offset = DefaultPaginationOffset
|
||||
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
offsetStr := r.URL.Query().Get("offset")
|
||||
if offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
||||
offset = o
|
||||
}
|
||||
}
|
||||
|
||||
return limit, offset
|
||||
}
|
||||
|
||||
func ValidateRedirectURL(redirectURL string) string {
|
||||
redirectURL = strings.TrimSpace(redirectURL)
|
||||
if redirectURL == "" || len(redirectURL) > 512 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(redirectURL, "/") || strings.HasPrefix(redirectURL, "//") {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(redirectURL)
|
||||
if err != nil || parsed.Scheme != "" || parsed.Host != "" || parsed.User != nil || parsed.Path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
path := parsed.EscapedPath()
|
||||
if path == "" {
|
||||
path = parsed.Path
|
||||
}
|
||||
|
||||
validated := path
|
||||
if parsed.RawQuery != "" {
|
||||
validated += "?" + parsed.RawQuery
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
validated += "#" + parsed.Fragment
|
||||
}
|
||||
|
||||
return validated
|
||||
}
|
||||
|
||||
func ParseUintParam(w http.ResponseWriter, r *http.Request, paramName, entityName string) (uint, bool) {
|
||||
str := chi.URLParam(r, paramName)
|
||||
if str == "" {
|
||||
SendErrorResponse(w, entityName+" ID is required", http.StatusBadRequest)
|
||||
return 0, false
|
||||
}
|
||||
id, err := strconv.ParseUint(str, 10, 32)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Invalid "+entityName+" ID", http.StatusBadRequest)
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
func RequireAuth(w http.ResponseWriter, r *http.Request) (uint, bool) {
|
||||
userID := middleware.GetUserIDFromContext(r.Context())
|
||||
if userID == 0 {
|
||||
SendErrorResponse(w, "Authentication required", http.StatusUnauthorized)
|
||||
return 0, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func NewVoteContext(r *http.Request) services.VoteContext {
|
||||
return services.VoteContext{
|
||||
UserID: middleware.GetUserIDFromContext(r.Context()),
|
||||
IPAddress: GetClientIP(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRepoError(w http.ResponseWriter, err error, entityName string) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SendErrorResponse(w, entityName+" not found", http.StatusNotFound)
|
||||
} else {
|
||||
SendErrorResponse(w, "Failed to retrieve "+entityName, http.StatusInternalServerError)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var AuthErrorMapping = []struct {
|
||||
err error
|
||||
msg string
|
||||
code int
|
||||
}{
|
||||
{services.ErrInvalidCredentials, "Invalid username or password", http.StatusUnauthorized},
|
||||
{services.ErrEmailNotVerified, "Please confirm your email before logging in", http.StatusForbidden},
|
||||
{services.ErrAccountLocked, "Your account has been locked. Please contact us for assistance.", http.StatusForbidden},
|
||||
{services.ErrUsernameTaken, "Username is already taken", http.StatusConflict},
|
||||
{services.ErrEmailTaken, "Email is already registered", http.StatusConflict},
|
||||
{services.ErrInvalidEmail, "Invalid email address", http.StatusBadRequest},
|
||||
{services.ErrPasswordTooShort, "Password must be at least 8 characters", http.StatusBadRequest},
|
||||
{services.ErrInvalidVerificationToken, "Invalid or expired verification token", http.StatusBadRequest},
|
||||
{services.ErrRefreshTokenExpired, "Refresh token has expired", http.StatusUnauthorized},
|
||||
{services.ErrRefreshTokenInvalid, "Invalid refresh token", http.StatusUnauthorized},
|
||||
{services.ErrInvalidDeletionToken, "This deletion link is invalid or has expired.", http.StatusBadRequest},
|
||||
{services.ErrDeletionRequestNotFound, "Deletion request not found", http.StatusBadRequest},
|
||||
{services.ErrUserNotFound, "User not found", http.StatusNotFound},
|
||||
{services.ErrEmailSenderUnavailable, "Email service is unavailable. Please try again later.", http.StatusServiceUnavailable},
|
||||
}
|
||||
|
||||
func HandleServiceError(w http.ResponseWriter, err error, defaultMsg string, defaultCode int) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, mapping := range AuthErrorMapping {
|
||||
if err == mapping.err || errors.Is(err, mapping.err) {
|
||||
SendErrorResponse(w, mapping.msg, mapping.code)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
errMsg := err.Error()
|
||||
for _, mapping := range AuthErrorMapping {
|
||||
if mapping.err.Error() == errMsg {
|
||||
SendErrorResponse(w, mapping.msg, mapping.code)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
SendErrorResponse(w, defaultMsg, defaultCode)
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"goyco/internal/fuzz"
|
||||
)
|
||||
|
||||
func FuzzJSONParsing(f *testing.F) {
|
||||
helper := fuzz.NewFuzzTestHelper()
|
||||
testCases := []map[string]any{
|
||||
{
|
||||
"name": "auth_login",
|
||||
"body": `{"username":"FUZZED_INPUT","password":"test"}`,
|
||||
},
|
||||
{
|
||||
"name": "auth_register",
|
||||
"body": `{"username":"FUZZED_INPUT","email":"test@example.com","password":"test123"}`,
|
||||
},
|
||||
{
|
||||
"name": "post_create",
|
||||
"body": `{"title":"FUZZED_INPUT","url":"https://example.com","content":"test"}`,
|
||||
},
|
||||
{
|
||||
"name": "vote_cast",
|
||||
"body": `{"type":"FUZZED_INPUT"}`,
|
||||
},
|
||||
}
|
||||
helper.RunJSONFuzzTest(f, testCases)
|
||||
}
|
||||
|
||||
func FuzzURLParsing(f *testing.F) {
|
||||
helper := fuzz.NewFuzzTestHelper()
|
||||
helper.RunBasicFuzzTest(f, func(t *testing.T, input string) {
|
||||
|
||||
sanitized := ""
|
||||
for _, char := range input {
|
||||
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') ||
|
||||
(char >= '0' && char <= '9') || char == '-' || char == '_' {
|
||||
sanitized += string(char)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sanitized) > 20 {
|
||||
sanitized = sanitized[:20]
|
||||
}
|
||||
|
||||
if len(sanitized) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
url := "/api/posts/" + sanitized
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
|
||||
pathParts := strings.Split(req.URL.Path, "/")
|
||||
if len(pathParts) >= 4 {
|
||||
idStr := pathParts[3]
|
||||
_ = idStr
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzQueryParameters(f *testing.F) {
|
||||
helper := fuzz.NewFuzzTestHelper()
|
||||
helper.RunBasicFuzzTest(f, func(t *testing.T, input string) {
|
||||
|
||||
if !utf8.ValidString(input) {
|
||||
return
|
||||
}
|
||||
|
||||
sanitized := ""
|
||||
for _, char := range input {
|
||||
|
||||
if char >= 32 && char <= 126 {
|
||||
switch char {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
|
||||
continue
|
||||
case '&':
|
||||
sanitized += "%26"
|
||||
case '=':
|
||||
sanitized += "%3D"
|
||||
case '?':
|
||||
sanitized += "%3F"
|
||||
case '#':
|
||||
sanitized += "%23"
|
||||
case '/':
|
||||
sanitized += "%2F"
|
||||
case '\\':
|
||||
sanitized += "%5C"
|
||||
default:
|
||||
sanitized += string(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(sanitized) > 100 {
|
||||
sanitized = sanitized[:100]
|
||||
}
|
||||
|
||||
if len(sanitized) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
query := "?q=" + sanitized + "&limit=10&offset=0"
|
||||
req := httptest.NewRequest("GET", "/api/posts/search"+query, nil)
|
||||
|
||||
q := req.URL.Query().Get("q")
|
||||
limit := req.URL.Query().Get("limit")
|
||||
offset := req.URL.Query().Get("offset")
|
||||
|
||||
if !utf8.ValidString(q) {
|
||||
|
||||
return
|
||||
}
|
||||
_ = limit
|
||||
_ = offset
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzHTTPHeaders(f *testing.F) {
|
||||
helper := fuzz.NewFuzzTestHelper()
|
||||
helper.RunBasicFuzzTest(f, func(t *testing.T, input string) {
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+input)
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("User-Agent", input)
|
||||
req.Header.Set("X-Forwarded-For", input)
|
||||
|
||||
for name, values := range req.Header {
|
||||
if !utf8.ValidString(name) {
|
||||
t.Fatal("Header name contains invalid UTF-8")
|
||||
}
|
||||
for _, value := range values {
|
||||
if !utf8.ValidString(value) {
|
||||
t.Fatal("Header value contains invalid UTF-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,464 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/dto"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/security"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/validation"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgconn"
|
||||
)
|
||||
|
||||
type PostHandler struct {
|
||||
postRepo repositories.PostRepository
|
||||
titleFetcher services.TitleFetcher
|
||||
voteService *services.VoteService
|
||||
postQueries *services.PostQueries
|
||||
}
|
||||
|
||||
func NewPostHandler(postRepo repositories.PostRepository, titleFetcher services.TitleFetcher, voteService *services.VoteService) *PostHandler {
|
||||
return &PostHandler{
|
||||
postRepo: postRepo,
|
||||
titleFetcher: titleFetcher,
|
||||
voteService: voteService,
|
||||
postQueries: services.NewPostQueries(postRepo, voteService),
|
||||
}
|
||||
}
|
||||
|
||||
type PostResponse = CommonResponse
|
||||
|
||||
type UpdatePostRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// @Summary Get posts
|
||||
// @Description Get a list of posts with pagination. Posts include vote statistics (up_votes, down_votes, score) and current user's vote status.
|
||||
// @Tags posts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of posts to return" default(20)
|
||||
// @Param offset query int false "Number of posts to skip" default(0)
|
||||
// @Success 200 {object} PostResponse "Posts retrieved successfully with vote statistics"
|
||||
// @Failure 400 {object} PostResponse "Invalid pagination parameters"
|
||||
// @Failure 500 {object} PostResponse "Internal server error"
|
||||
// @Router /posts [get]
|
||||
func (h *PostHandler) GetPosts(w http.ResponseWriter, r *http.Request) {
|
||||
limit, offset := parsePagination(r)
|
||||
|
||||
opts := services.QueryOptions{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
ctx := NewVoteContext(r)
|
||||
|
||||
posts, err := h.postQueries.GetAll(opts, ctx)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Failed to fetch posts", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
postDTOs := dto.ToPostDTOs(posts)
|
||||
SendSuccessResponse(w, "Posts retrieved successfully", map[string]any{
|
||||
"posts": postDTOs,
|
||||
"count": len(postDTOs),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Get a single post
|
||||
// @Description Get a post by ID with vote statistics and current user's vote status
|
||||
// @Tags posts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Post ID"
|
||||
// @Success 200 {object} PostResponse "Post retrieved successfully with vote statistics"
|
||||
// @Failure 400 {object} PostResponse "Invalid post ID"
|
||||
// @Failure 404 {object} PostResponse "Post not found"
|
||||
// @Failure 500 {object} PostResponse "Internal server error"
|
||||
// @Router /posts/{id} [get]
|
||||
func (h *PostHandler) GetPost(w http.ResponseWriter, r *http.Request) {
|
||||
postID, ok := ParseUintParam(w, r, "id", "Post")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := NewVoteContext(r)
|
||||
|
||||
post, err := h.postQueries.GetByID(postID, ctx)
|
||||
if !HandleRepoError(w, err, "Post") {
|
||||
return
|
||||
}
|
||||
|
||||
postDTO := dto.ToPostDTO(post)
|
||||
SendSuccessResponse(w, "Post retrieved successfully", postDTO)
|
||||
}
|
||||
|
||||
// @Summary Create a new post
|
||||
// @Description Create a new post with URL and optional title
|
||||
// @Tags posts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body CreatePostRequest true "Post data"
|
||||
// @Success 201 {object} PostResponse
|
||||
// @Failure 400 {object} PostResponse "Invalid request data or validation failed"
|
||||
// @Failure 401 {object} PostResponse "Authentication required"
|
||||
// @Failure 409 {object} PostResponse "URL already submitted"
|
||||
// @Failure 502 {object} PostResponse "Failed to fetch title from URL"
|
||||
// @Failure 500 {object} PostResponse "Internal server error"
|
||||
// @Router /posts [post]
|
||||
func (h *PostHandler) CreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
req.Title = security.SanitizeInput(req.Title)
|
||||
req.URL = security.SanitizeURL(req.URL)
|
||||
req.Content = security.SanitizePostContent(req.Content)
|
||||
|
||||
if req.URL == "" {
|
||||
SendErrorResponse(w, "URL is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Title) > 200 {
|
||||
SendErrorResponse(w, "Title must be no more than 200 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Content) > 10000 {
|
||||
SendErrorResponse(w, "Content must be no more than 10,000 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
title := req.Title
|
||||
|
||||
if title == "" && h.titleFetcher != nil {
|
||||
titleCtx, cancel := context.WithTimeout(r.Context(), 7*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fetchedTitle, err := h.titleFetcher.FetchTitle(titleCtx, req.URL)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrUnsupportedScheme):
|
||||
SendErrorResponse(w, "Only HTTP and HTTPS URLs are supported", http.StatusBadRequest)
|
||||
case errors.Is(err, services.ErrTitleNotFound):
|
||||
SendErrorResponse(w, "Title could not be extracted from the provided URL", http.StatusBadRequest)
|
||||
default:
|
||||
SendErrorResponse(w, "Failed to fetch title from URL", http.StatusBadGateway)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
title = fetchedTitle
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
SendErrorResponse(w, "Title is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(title) < 3 {
|
||||
SendErrorResponse(w, "Title must be at least 3 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
post := &database.Post{
|
||||
Title: title,
|
||||
URL: req.URL,
|
||||
Content: req.Content,
|
||||
AuthorID: &userID,
|
||||
}
|
||||
|
||||
if err := h.postRepo.Create(post); err != nil {
|
||||
if errMsg, status := translatePostCreateError(err); status != 0 {
|
||||
SendErrorResponse(w, errMsg, status)
|
||||
return
|
||||
}
|
||||
|
||||
SendErrorResponse(w, "Failed to create post", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
postDTO := dto.ToPostDTO(post)
|
||||
SendCreatedResponse(w, "Post created successfully", postDTO)
|
||||
}
|
||||
|
||||
// @Summary Search posts
|
||||
// @Description Search posts by title or content keywords. Results include vote statistics and current user's vote status.
|
||||
// @Tags posts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param q query string false "Search term"
|
||||
// @Param limit query int false "Number of posts to return" default(20)
|
||||
// @Param offset query int false "Number of posts to skip" default(0)
|
||||
// @Success 200 {object} PostResponse "Search results with vote statistics"
|
||||
// @Failure 400 {object} PostResponse "Invalid search parameters"
|
||||
// @Failure 500 {object} PostResponse "Internal server error"
|
||||
// @Router /posts/search [get]
|
||||
func (h *PostHandler) SearchPosts(w http.ResponseWriter, r *http.Request) {
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
limit, offset := parsePagination(r)
|
||||
|
||||
opts := services.QueryOptions{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
ctx := NewVoteContext(r)
|
||||
|
||||
posts, err := h.postQueries.GetSearch(query, opts, ctx)
|
||||
if err != nil {
|
||||
if searchErr, ok := err.(*repositories.SearchError); ok {
|
||||
SendErrorResponse(w, "Invalid search query: "+searchErr.Message, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
SendErrorResponse(w, "Failed to search posts", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
postDTOs := dto.ToPostDTOs(posts)
|
||||
SendSuccessResponse(w, "Search results retrieved successfully", map[string]any{
|
||||
"posts": postDTOs,
|
||||
"count": len(postDTOs),
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Update a post
|
||||
// @Description Update the title and content of a post owned by the authenticated user
|
||||
// @Tags posts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Post ID"
|
||||
// @Param request body UpdatePostRequest true "Post update data"
|
||||
// @Success 200 {object} PostResponse "Post updated successfully"
|
||||
// @Failure 400 {object} PostResponse "Invalid request data or validation failed"
|
||||
// @Failure 401 {object} PostResponse "Authentication required"
|
||||
// @Failure 403 {object} PostResponse "Not authorized to update this post"
|
||||
// @Failure 404 {object} PostResponse "Post not found"
|
||||
// @Failure 500 {object} PostResponse "Internal server error"
|
||||
// @Router /posts/{id} [put]
|
||||
func (h *PostHandler) UpdatePost(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
postID, ok := ParseUintParam(w, r, "id", "Post")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
post, err := h.postRepo.GetByID(postID)
|
||||
if !HandleRepoError(w, err, "Post") {
|
||||
return
|
||||
}
|
||||
|
||||
if post.AuthorID == nil || *post.AuthorID != userID {
|
||||
SendErrorResponse(w, "You can only edit your own posts", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
req.Title = security.SanitizeInput(req.Title)
|
||||
req.Content = security.SanitizePostContent(req.Content)
|
||||
|
||||
if len(req.Title) > 200 {
|
||||
SendErrorResponse(w, "Title must be no more than 200 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Content) > 10000 {
|
||||
SendErrorResponse(w, "Content must be no more than 10,000 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidateTitle(req.Title); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidateContent(req.Content); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
post.Title = req.Title
|
||||
post.Content = req.Content
|
||||
|
||||
if err := h.postRepo.Update(post); err != nil {
|
||||
SendErrorResponse(w, "Failed to update post", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
postDTO := dto.ToPostDTO(post)
|
||||
SendSuccessResponse(w, "Post updated successfully", postDTO)
|
||||
}
|
||||
|
||||
// @Summary Delete a post
|
||||
// @Description Delete a post owned by the authenticated user
|
||||
// @Tags posts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Post ID"
|
||||
// @Success 200 {object} PostResponse "Post deleted successfully"
|
||||
// @Failure 400 {object} PostResponse "Invalid post ID"
|
||||
// @Failure 401 {object} PostResponse "Authentication required"
|
||||
// @Failure 403 {object} PostResponse "Not authorized to delete this post"
|
||||
// @Failure 404 {object} PostResponse "Post not found"
|
||||
// @Failure 500 {object} PostResponse "Internal server error"
|
||||
// @Router /posts/{id} [delete]
|
||||
func (h *PostHandler) DeletePost(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
postID, ok := ParseUintParam(w, r, "id", "Post")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
post, err := h.postRepo.GetByID(postID)
|
||||
if !HandleRepoError(w, err, "Post") {
|
||||
return
|
||||
}
|
||||
|
||||
if post.AuthorID == nil || *post.AuthorID != userID {
|
||||
SendErrorResponse(w, "You can only delete your own posts", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.voteService.DeleteVotesByPostID(postID); err != nil {
|
||||
SendErrorResponse(w, "Failed to delete post votes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.postRepo.Delete(postID); err != nil {
|
||||
SendErrorResponse(w, "Failed to delete post", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Post deleted successfully", nil)
|
||||
}
|
||||
|
||||
// @Summary Fetch title from URL
|
||||
// @Description Fetch the HTML title for the provided URL
|
||||
// @Tags posts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param url query string true "URL to inspect"
|
||||
// @Success 200 {object} PostResponse "Title fetched successfully"
|
||||
// @Failure 400 {object} PostResponse "Invalid URL or URL parameter missing"
|
||||
// @Failure 501 {object} PostResponse "Title fetching is not available"
|
||||
// @Failure 502 {object} PostResponse "Failed to fetch title from URL"
|
||||
// @Router /posts/title [get]
|
||||
func (h *PostHandler) FetchTitleFromURL(w http.ResponseWriter, r *http.Request) {
|
||||
if h.titleFetcher == nil {
|
||||
SendErrorResponse(w, "Title fetching is not available", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
requestedURL := strings.TrimSpace(r.URL.Query().Get("url"))
|
||||
if requestedURL == "" {
|
||||
SendErrorResponse(w, "URL query parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
titleCtx, cancel := context.WithTimeout(r.Context(), 7*time.Second)
|
||||
defer cancel()
|
||||
|
||||
title, err := h.titleFetcher.FetchTitle(titleCtx, requestedURL)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrUnsupportedScheme):
|
||||
SendErrorResponse(w, "Only HTTP and HTTPS URLs are supported", http.StatusBadRequest)
|
||||
case errors.Is(err, services.ErrTitleNotFound):
|
||||
SendErrorResponse(w, "Title could not be extracted from the provided URL", http.StatusBadRequest)
|
||||
default:
|
||||
SendErrorResponse(w, "Failed to fetch title from URL", http.StatusBadGateway)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Title fetched successfully", map[string]string{
|
||||
"title": title,
|
||||
})
|
||||
}
|
||||
|
||||
func translatePostCreateError(err error) (string, int) {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "23505":
|
||||
return "This URL has already been submitted.", http.StatusConflict
|
||||
case "23503":
|
||||
return "Author account not found. Please sign in again.", http.StatusUnauthorized
|
||||
}
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "UNIQUE constraint") || strings.Contains(errStr, "duplicate") {
|
||||
return "This URL has already been submitted.", http.StatusConflict
|
||||
}
|
||||
|
||||
return "", 0
|
||||
}
|
||||
|
||||
func (h *PostHandler) MountRoutes(r chi.Router, config RouteModuleConfig) {
|
||||
public := r
|
||||
if config.GeneralRateLimit != nil {
|
||||
public = config.GeneralRateLimit(r)
|
||||
}
|
||||
public.Get("/posts", h.GetPosts)
|
||||
public.Get("/posts/search", h.SearchPosts)
|
||||
public.Get("/posts/title", h.FetchTitleFromURL)
|
||||
public.Get("/posts/{id}", h.GetPost)
|
||||
|
||||
protected := r
|
||||
if config.AuthMiddleware != nil {
|
||||
protected = r.With(config.AuthMiddleware)
|
||||
}
|
||||
if config.GeneralRateLimit != nil {
|
||||
protected = config.GeneralRateLimit(protected)
|
||||
}
|
||||
protected.Post("/posts", h.CreatePost)
|
||||
protected.Put("/posts/{id}", h.UpdatePost)
|
||||
protected.Delete("/posts/{id}", h.DeletePost)
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgconn"
|
||||
"gorm.io/gorm"
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func decodeHandlerResponse(t *testing.T, rr *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestPostHandlerGetPostsWithVoteService(t *testing.T) {
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
repo.GetAllFn = func(limit, offset int) ([]database.Post, error) {
|
||||
return []database.Post{
|
||||
{ID: 1, Title: "Test Post 1"},
|
||||
{ID: 2, Title: "Test Post 2"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
voteRepo := testutils.NewMockVoteRepository()
|
||||
voteService := services.NewVoteService(voteRepo, repo, nil)
|
||||
handler := NewPostHandler(repo, nil, voteService)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/posts", nil)
|
||||
request = testutils.WithUserContext(request, middleware.UserIDKey, uint(1))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.GetPosts(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
payload := decodeHandlerResponse(t, recorder)
|
||||
if !payload["success"].(bool) {
|
||||
t.Fatalf("expected success response, got %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandlerCreatePostWithTitleFetcher(t *testing.T) {
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
var storedPost *database.Post
|
||||
repo.CreateFn = func(post *database.Post) error {
|
||||
storedPost = post
|
||||
return nil
|
||||
}
|
||||
|
||||
titleFetcher := &testutils.MockTitleFetcher{}
|
||||
titleFetcher.SetTitle("Fetched Title")
|
||||
|
||||
handler := NewPostHandler(repo, titleFetcher, nil)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts", bytes.NewBufferString(`{"url":"https://example.com","content":"Test content"}`))
|
||||
request = testutils.WithUserContext(request, middleware.UserIDKey, uint(1))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.CreatePost(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusCreated)
|
||||
|
||||
if storedPost == nil {
|
||||
t.Fatal("expected post to be created")
|
||||
}
|
||||
|
||||
if storedPost.Title != "Fetched Title" {
|
||||
t.Errorf("expected title 'Fetched Title', got %s", storedPost.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandlerCreatePostTitleFetcherError(t *testing.T) {
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
titleFetcher := &testutils.MockTitleFetcher{}
|
||||
titleFetcher.SetError(services.ErrUnsupportedScheme)
|
||||
|
||||
handler := NewPostHandler(repo, titleFetcher, nil)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts", bytes.NewBufferString(`{"url":"ftp://example.com"}`))
|
||||
request = testutils.WithUserContext(request, middleware.UserIDKey, uint(1))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.CreatePost(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
||||
|
||||
payload := decodeHandlerResponse(t, recorder)
|
||||
if payload["success"].(bool) {
|
||||
t.Fatalf("expected error response, got %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandlerSearchPosts(t *testing.T) {
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
repo.SearchFn = func(query string, limit, offset int) ([]database.Post, error) {
|
||||
return []database.Post{
|
||||
{ID: 1, Title: "Search Result 1"},
|
||||
{ID: 2, Title: "Search Result 2"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
handler := NewPostHandler(repo, nil, nil)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/posts/search?q=test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.SearchPosts(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
payload := decodeHandlerResponse(t, recorder)
|
||||
if !payload["success"].(bool) {
|
||||
t.Fatalf("expected success response, got %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandlerFetchTitleFromURL(t *testing.T) {
|
||||
titleFetcher := &testutils.MockTitleFetcher{}
|
||||
titleFetcher.SetTitle("Test Title")
|
||||
|
||||
handler := NewPostHandler(nil, titleFetcher, nil)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/posts/title?url=https://example.com", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.FetchTitleFromURL(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
payload := decodeHandlerResponse(t, recorder)
|
||||
if !payload["success"].(bool) {
|
||||
t.Fatalf("expected success response, got %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandlerFetchTitleFromURLNoFetcher(t *testing.T) {
|
||||
handler := NewPostHandler(nil, nil, nil)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/posts/title?url=https://example.com", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.FetchTitleFromURL(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusNotImplemented)
|
||||
}
|
||||
|
||||
func TestPostHandlerUpdatePostUnauthorized(t *testing.T) {
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
return &database.Post{ID: id, AuthorID: func() *uint { u := uint(2); return &u }()}, nil
|
||||
}
|
||||
|
||||
handler := NewPostHandler(repo, nil, nil)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/posts/1", bytes.NewBufferString(`{"title":"Updated Title","content":"Updated content"}`))
|
||||
request = testutils.WithUserContext(request, middleware.UserIDKey, uint(1))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.UpdatePost(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestPostHandlerDeletePostUnauthorized(t *testing.T) {
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
return &database.Post{ID: id, AuthorID: func() *uint { u := uint(2); return &u }()}, nil
|
||||
}
|
||||
|
||||
voteRepo := testutils.NewMockVoteRepository()
|
||||
voteService := services.NewVoteService(voteRepo, repo, nil)
|
||||
handler := NewPostHandler(repo, nil, voteService)
|
||||
|
||||
request := httptest.NewRequest(http.MethodDelete, "/api/posts/1", nil)
|
||||
request = testutils.WithUserContext(request, middleware.UserIDKey, uint(1))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.DeletePost(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestPostHandlerGetPosts(t *testing.T) {
|
||||
var receivedLimit, receivedOffset int
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
repo.GetAllFn = func(limit, offset int) ([]database.Post, error) {
|
||||
receivedLimit = limit
|
||||
receivedOffset = offset
|
||||
return []database.Post{{ID: 1}}, nil
|
||||
}
|
||||
|
||||
handler := NewPostHandler(repo, nil, nil)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/posts?limit=5&offset=2", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.GetPosts(recorder, request)
|
||||
|
||||
if receivedLimit != 5 || receivedOffset != 2 {
|
||||
t.Fatalf("expected limit=5 offset=2, got %d %d", receivedLimit, receivedOffset)
|
||||
}
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
payload := decodeHandlerResponse(t, recorder)
|
||||
if !payload["success"].(bool) {
|
||||
t.Fatalf("expected success response, got %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandlerGetPostErrors(t *testing.T) {
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
handler := NewPostHandler(repo, nil, nil)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/posts", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.GetPost(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing id, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/abc", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "abc"})
|
||||
recorder = httptest.NewRecorder()
|
||||
handler.GetPost(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid id, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
repo.GetByIDFn = func(uint) (*database.Post, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/1", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
recorder = httptest.NewRecorder()
|
||||
handler.GetPost(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestPostHandlerCreatePostSuccess(t *testing.T) {
|
||||
var storedPost *database.Post
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
repo.CreateFn = func(post *database.Post) error {
|
||||
storedPost = &database.Post{
|
||||
Title: post.Title,
|
||||
URL: post.URL,
|
||||
Content: post.Content,
|
||||
AuthorID: post.AuthorID,
|
||||
}
|
||||
storedPost.ID = 1
|
||||
return nil
|
||||
}
|
||||
fetcher := &testutils.TitleFetcherStub{FetchTitleFn: func(ctx context.Context, rawURL string) (string, error) {
|
||||
return "Fetched Title", nil
|
||||
}}
|
||||
|
||||
handler := NewPostHandler(repo, fetcher, nil)
|
||||
|
||||
body := bytes.NewBufferString(`{"title":" ","url":"https://example.com","content":"Go"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts", body)
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(42))
|
||||
request = request.WithContext(ctx)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.CreatePost(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusCreated)
|
||||
|
||||
if storedPost == nil || storedPost.Title != "Fetched Title" || storedPost.AuthorID == nil || *storedPost.AuthorID != 42 {
|
||||
t.Fatalf("unexpected stored post: %#v", storedPost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandlerCreatePostValidation(t *testing.T) {
|
||||
handler := NewPostHandler(testutils.NewPostRepositoryStub(), &testutils.TitleFetcherStub{}, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts", bytes.NewBufferString(`{"title":"","url":"","content":""}`))
|
||||
request = request.WithContext(context.WithValue(request.Context(), middleware.UserIDKey, uint(1)))
|
||||
handler.CreatePost(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing url, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts", bytes.NewBufferString(`invalid json`))
|
||||
handler.CreatePost(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid JSON, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts", bytes.NewBufferString(`{"title":"ok","url":"https://example.com"}`))
|
||||
handler.CreatePost(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func TestPostHandlerCreatePostTitleFetcherErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus int
|
||||
wantMsg string
|
||||
}{
|
||||
{name: "Unsupported", err: services.ErrUnsupportedScheme, wantStatus: http.StatusBadRequest, wantMsg: "Only HTTP and HTTPS URLs are supported"},
|
||||
{name: "TitleMissing", err: services.ErrTitleNotFound, wantStatus: http.StatusBadRequest, wantMsg: "Title could not be extracted"},
|
||||
{name: "Generic", err: errors.New("timeout"), wantStatus: http.StatusBadGateway, wantMsg: "Failed to fetch title"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
fetcher := &testutils.TitleFetcherStub{FetchTitleFn: func(ctx context.Context, rawURL string) (string, error) {
|
||||
return "", tc.err
|
||||
}}
|
||||
handler := NewPostHandler(repo, fetcher, nil)
|
||||
body := bytes.NewBufferString(`{"title":" ","url":"https://example.com"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts", body)
|
||||
request = request.WithContext(context.WithValue(request.Context(), middleware.UserIDKey, uint(1)))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.CreatePost(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, tc.wantStatus)
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), tc.wantMsg) {
|
||||
t.Fatalf("expected message to contain %q, got %q", tc.wantMsg, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandlerFetchTitleFromURLErrors(t *testing.T) {
|
||||
handler := NewPostHandler(testutils.NewPostRepositoryStub(), nil, nil)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/posts/title?url=https://example.com", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.FetchTitleFromURL(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusNotImplemented {
|
||||
t.Fatalf("expected 501 when fetcher unavailable, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
handler = NewPostHandler(testutils.NewPostRepositoryStub(), &testutils.TitleFetcherStub{}, nil)
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/title", nil)
|
||||
recorder = httptest.NewRecorder()
|
||||
handler.FetchTitleFromURL(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing url query, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
handler = NewPostHandler(testutils.NewPostRepositoryStub(), &testutils.TitleFetcherStub{FetchTitleFn: func(ctx context.Context, rawURL string) (string, error) {
|
||||
return "", errors.New("failed")
|
||||
}}, nil)
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/title?url=https://example.com", nil)
|
||||
recorder = httptest.NewRecorder()
|
||||
handler.FetchTitleFromURL(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusBadGateway)
|
||||
}
|
||||
|
||||
func TestTranslatePostCreateError(t *testing.T) {
|
||||
conflictErr := &pgconn.PgError{Code: "23505"}
|
||||
msg, status := translatePostCreateError(conflictErr)
|
||||
if status != http.StatusConflict || !strings.Contains(msg, "already been submitted") {
|
||||
t.Fatalf("unexpected conflict translation: status=%d msg=%q", status, msg)
|
||||
}
|
||||
|
||||
fkErr := &pgconn.PgError{Code: "23503"}
|
||||
msg, status = translatePostCreateError(fkErr)
|
||||
if status != http.StatusUnauthorized || !strings.Contains(msg, "Author account not found") {
|
||||
t.Fatalf("unexpected foreign key translation: status=%d msg=%q", status, msg)
|
||||
}
|
||||
|
||||
msg, status = translatePostCreateError(errors.New("other"))
|
||||
if status != 0 || msg != "" {
|
||||
t.Fatalf("expected passthrough for unrelated errors, got status=%d msg=%q", status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandlerUpdatePost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
postID string
|
||||
requestBody string
|
||||
userID uint
|
||||
mockSetup func(*testutils.PostRepositoryStub)
|
||||
expectedStatus int
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "valid post update",
|
||||
postID: "1",
|
||||
requestBody: `{"title": "Updated Title", "content": "Updated content"}`,
|
||||
userID: 1,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
authorID := uint(1)
|
||||
return &database.Post{ID: id, Title: "Old Title", AuthorID: &authorID}, nil
|
||||
}
|
||||
repo.UpdateFn = func(post *database.Post) error { return nil }
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "missing user context",
|
||||
postID: "1",
|
||||
requestBody: `{"title": "Updated Title", "content": "Updated content"}`,
|
||||
userID: 0,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
expectedError: "Authentication required",
|
||||
},
|
||||
{
|
||||
name: "post not found",
|
||||
postID: "999",
|
||||
requestBody: `{"title": "Updated Title", "content": "Updated content"}`,
|
||||
userID: 1,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
},
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedError: "Post not found",
|
||||
},
|
||||
{
|
||||
name: "not author",
|
||||
postID: "1",
|
||||
requestBody: `{"title": "Updated Title", "content": "Updated content"}`,
|
||||
userID: 2,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
authorID := uint(1)
|
||||
return &database.Post{ID: id, Title: "Old Title", AuthorID: &authorID}, nil
|
||||
}
|
||||
},
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedError: "You can only edit your own posts",
|
||||
},
|
||||
{
|
||||
name: "empty title",
|
||||
postID: "1",
|
||||
requestBody: `{"title": "", "content": "Updated content"}`,
|
||||
userID: 1,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {
|
||||
authorID := uint(1)
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
return &database.Post{ID: id, Title: "Old Title", AuthorID: &authorID}, nil
|
||||
}
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Title is required",
|
||||
},
|
||||
{
|
||||
name: "short title",
|
||||
postID: "1",
|
||||
requestBody: `{"title": "ab", "content": "Updated content"}`,
|
||||
userID: 1,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {
|
||||
authorID := uint(1)
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
return &database.Post{ID: id, Title: "Old Title", AuthorID: &authorID}, nil
|
||||
}
|
||||
},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedError: "Title must be at least 3 characters",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
if tt.mockSetup != nil {
|
||||
tt.mockSetup(repo)
|
||||
}
|
||||
handler := NewPostHandler(repo, &testutils.TitleFetcherStub{}, nil)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/posts/"+tt.postID, bytes.NewBufferString(tt.requestBody))
|
||||
if tt.userID > 0 {
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, tt.userID)
|
||||
request = request.WithContext(ctx)
|
||||
}
|
||||
|
||||
ctx := chi.NewRouteContext()
|
||||
ctx.URLParams.Add("id", tt.postID)
|
||||
request = request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, ctx))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.UpdatePost(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
if !strings.Contains(recorder.Body.String(), tt.expectedError) {
|
||||
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandlerDeletePost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
postID string
|
||||
userID uint
|
||||
mockSetup func(*testutils.PostRepositoryStub)
|
||||
expectedStatus int
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "valid post deletion",
|
||||
postID: "1",
|
||||
userID: 1,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
authorID := uint(1)
|
||||
return &database.Post{ID: id, Title: "Test Post", AuthorID: &authorID}, nil
|
||||
}
|
||||
repo.DeleteFn = func(id uint) error { return nil }
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "missing user context",
|
||||
postID: "1",
|
||||
userID: 0,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {},
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
expectedError: "Authentication required",
|
||||
},
|
||||
{
|
||||
name: "post not found",
|
||||
postID: "999",
|
||||
userID: 1,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
},
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedError: "Post not found",
|
||||
},
|
||||
{
|
||||
name: "not author",
|
||||
postID: "1",
|
||||
userID: 2,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
authorID := uint(1)
|
||||
return &database.Post{ID: id, Title: "Test Post", AuthorID: &authorID}, nil
|
||||
}
|
||||
},
|
||||
expectedStatus: http.StatusForbidden,
|
||||
expectedError: "You can only delete your own posts",
|
||||
},
|
||||
{
|
||||
name: "delete error",
|
||||
postID: "1",
|
||||
userID: 1,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
authorID := uint(1)
|
||||
return &database.Post{ID: id, Title: "Test Post", AuthorID: &authorID}, nil
|
||||
}
|
||||
repo.DeleteFn = func(id uint) error { return errors.New("database error") }
|
||||
},
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectedError: "Failed to delete post",
|
||||
},
|
||||
{
|
||||
name: "delete votes error",
|
||||
postID: "1",
|
||||
userID: 1,
|
||||
mockSetup: func(repo *testutils.PostRepositoryStub) {
|
||||
repo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
authorID := uint(1)
|
||||
return &database.Post{ID: id, Title: "Test Post", AuthorID: &authorID}, nil
|
||||
}
|
||||
},
|
||||
expectedStatus: http.StatusInternalServerError,
|
||||
expectedError: "Failed to delete post votes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := testutils.NewPostRepositoryStub()
|
||||
if tt.mockSetup != nil {
|
||||
tt.mockSetup(repo)
|
||||
}
|
||||
|
||||
var voteService *services.VoteService
|
||||
if tt.name == "delete votes error" {
|
||||
voteRepo := &errorVoteRepository{}
|
||||
voteService = services.NewVoteService(voteRepo, repo, nil)
|
||||
} else {
|
||||
voteRepo := testutils.NewMockVoteRepository()
|
||||
voteService = services.NewVoteService(voteRepo, repo, nil)
|
||||
}
|
||||
|
||||
handler := NewPostHandler(repo, &testutils.TitleFetcherStub{}, voteService)
|
||||
|
||||
request := httptest.NewRequest(http.MethodDelete, "/api/posts/"+tt.postID, nil)
|
||||
if tt.userID > 0 {
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, tt.userID)
|
||||
request = request.WithContext(ctx)
|
||||
}
|
||||
|
||||
ctx := chi.NewRouteContext()
|
||||
ctx.URLParams.Add("id", tt.postID)
|
||||
request = request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, ctx))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.DeletePost(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
if !strings.Contains(recorder.Body.String(), tt.expectedError) {
|
||||
t.Fatalf("expected error to contain %q, got %q", tt.expectedError, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type errorVoteRepository struct{}
|
||||
|
||||
func (e *errorVoteRepository) Create(*database.Vote) error { return nil }
|
||||
func (e *errorVoteRepository) CreateOrUpdate(*database.Vote) error { return nil }
|
||||
func (e *errorVoteRepository) GetByID(uint) (*database.Vote, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
func (e *errorVoteRepository) GetByUserAndPost(uint, uint) (*database.Vote, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
func (e *errorVoteRepository) GetByVoteHash(string) (*database.Vote, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
func (e *errorVoteRepository) GetByPostID(uint) ([]database.Vote, error) {
|
||||
return nil, errors.New("database error")
|
||||
}
|
||||
func (e *errorVoteRepository) GetByUserID(uint) ([]database.Vote, error) { return nil, nil }
|
||||
func (e *errorVoteRepository) Update(*database.Vote) error { return nil }
|
||||
func (e *errorVoteRepository) Delete(uint) error { return nil }
|
||||
func (e *errorVoteRepository) Count() (int64, error) { return 0, nil }
|
||||
func (e *errorVoteRepository) CountByPostID(uint) (int64, error) { return 0, nil }
|
||||
func (e *errorVoteRepository) CountByUserID(uint) (int64, error) { return 0, nil }
|
||||
func (e *errorVoteRepository) WithTx(*gorm.DB) repositories.VoteRepository { return e }
|
||||
|
||||
func TestPostHandler_EdgeCases(t *testing.T) {
|
||||
postRepo := testutils.NewPostRepositoryStub()
|
||||
titleFetcher := &testutils.TitleFetcherStub{}
|
||||
handler := NewPostHandler(postRepo, titleFetcher, nil)
|
||||
|
||||
t.Run("GetPosts with zero limit", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/posts?limit=0", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetPosts(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for zero limit, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetPosts with negative limit", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/posts?limit=-1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetPosts(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for negative limit, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetPosts with negative offset", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/posts?offset=-1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetPosts(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for negative offset, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type RouteModule interface {
|
||||
MountRoutes(r chi.Router, config RouteModuleConfig)
|
||||
}
|
||||
|
||||
type RouteModuleConfig struct {
|
||||
AuthService middleware.TokenVerifier
|
||||
GeneralRateLimit func(chi.Router) chi.Router
|
||||
AuthRateLimit func(chi.Router) chi.Router
|
||||
CSRFMiddleware func(http.Handler) http.Handler
|
||||
AuthMiddleware func(http.Handler) http.Handler
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/security"
|
||||
"goyco/internal/testutils"
|
||||
"goyco/internal/validation"
|
||||
)
|
||||
|
||||
func TestPostHandler_XSSProtection_Comprehensive(t *testing.T) {
|
||||
maliciousInputs := testutils.GetMaliciousInputs()
|
||||
|
||||
for _, payload := range maliciousInputs.XSSPayloads {
|
||||
t.Run("XSS_"+payload[:minLen(20, len(payload))], func(t *testing.T) {
|
||||
repo := &testutils.PostRepositoryStub{
|
||||
CreateFn: func(post *database.Post) error {
|
||||
sanitizedTitle := security.SanitizeInput(payload)
|
||||
if post.Title != sanitizedTitle {
|
||||
t.Errorf("Expected sanitized title, got %q", post.Title)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewPostHandler(repo, nil, nil)
|
||||
|
||||
postData := map[string]string{
|
||||
"title": payload,
|
||||
"url": "https://example.com",
|
||||
"content": "Test content",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(postData)
|
||||
request := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request = request.WithContext(context.WithValue(request.Context(), middleware.UserIDKey, uint(1)))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.CreatePost(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusCreated)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func minLen(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestPostHandler_InputValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
content string
|
||||
url string
|
||||
expectedStatus int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "title too long",
|
||||
title: string(make([]byte, 201)),
|
||||
content: "Normal content",
|
||||
url: "https://example.com",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Title should be limited to 200 characters",
|
||||
},
|
||||
{
|
||||
name: "content too long",
|
||||
title: "Normal title",
|
||||
content: string(make([]byte, 10001)),
|
||||
url: "https://example.com",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Content should be limited to 10,000 characters",
|
||||
},
|
||||
{
|
||||
name: "invalid URL protocol",
|
||||
title: "Normal title",
|
||||
content: "Normal content",
|
||||
url: "ftp://example.com",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Only HTTP and HTTPS URLs should be allowed",
|
||||
},
|
||||
{
|
||||
name: "localhost URL blocked",
|
||||
title: "Normal title",
|
||||
content: "Normal content",
|
||||
url: "http://localhost:8080",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Localhost URLs should be blocked",
|
||||
},
|
||||
{
|
||||
name: "private IP URL blocked",
|
||||
title: "Normal title",
|
||||
content: "Normal content",
|
||||
url: "http://192.168.1.1",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Private IP URLs should be blocked",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := &testutils.PostRepositoryStub{}
|
||||
handler := NewPostHandler(repo, nil, nil)
|
||||
|
||||
postData := map[string]string{
|
||||
"title": tt.title,
|
||||
"url": tt.url,
|
||||
"content": tt.content,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(postData)
|
||||
request := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request = request.WithContext(context.WithValue(request.Context(), middleware.UserIDKey, uint(1)))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.CreatePost(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_PasswordValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
expectedStatus int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "weak password",
|
||||
password: "123",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Weak passwords should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password without letters",
|
||||
password: "12345678",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Passwords without letters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password without numbers",
|
||||
password: "password",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Passwords without numbers should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password without special chars",
|
||||
password: "Password123",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Passwords without special characters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password too short",
|
||||
password: "Pass1!",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Passwords shorter than 8 characters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password too long",
|
||||
password: string(make([]byte, 129)),
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Passwords that are too long should be rejected",
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
password: "",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Empty passwords should be rejected",
|
||||
},
|
||||
{
|
||||
name: "valid password",
|
||||
password: "Password123!",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Valid passwords should be accepted",
|
||||
},
|
||||
{
|
||||
name: "valid password with underscore",
|
||||
password: "Password123_",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Valid passwords with underscore should be accepted",
|
||||
},
|
||||
{
|
||||
name: "valid password with hyphen",
|
||||
password: "Password123-",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Valid passwords with hyphen should be accepted",
|
||||
},
|
||||
{
|
||||
name: "valid password with unicode",
|
||||
password: "Pássw0rd123!",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Valid passwords with unicode should be accepted",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := &testutils.UserRepositoryStub{
|
||||
GetByUsernameFn: func(string) (*database.User, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
CreateFn: func(user *database.User) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := newAuthHandler(repo)
|
||||
|
||||
registerData := map[string]string{
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": tt.password,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(registerData)
|
||||
request := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.Register(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_UsernameSanitization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
expectedStatus int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "username with special chars",
|
||||
username: "test@user#123",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Special characters should be removed from username",
|
||||
},
|
||||
{
|
||||
name: "username with script tags",
|
||||
username: "test<script>alert('xss')</script>user",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Script tags should be removed from username",
|
||||
},
|
||||
{
|
||||
name: "username starting with special char",
|
||||
username: "@testuser",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Username starting with special char should be prefixed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedUsername string
|
||||
repo := &testutils.UserRepositoryStub{
|
||||
GetByUsernameFn: func(username string) (*database.User, error) {
|
||||
capturedUsername = username
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
CreateFn: func(user *database.User) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := newAuthHandler(repo)
|
||||
|
||||
registerData := map[string]string{
|
||||
"username": tt.username,
|
||||
"email": "test@example.com",
|
||||
"password": "Password123!",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(registerData)
|
||||
request := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.Register(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
||||
|
||||
expectedUsername := security.SanitizeUsername(tt.username)
|
||||
if capturedUsername != expectedUsername {
|
||||
t.Errorf("Expected sanitized username %q, got %q", expectedUsername, capturedUsername)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHandler_AuthorizationBypass(t *testing.T) {
|
||||
repo := &testutils.PostRepositoryStub{
|
||||
GetByIDFn: func(id uint) (*database.Post, error) {
|
||||
authorID := uint(2)
|
||||
return &database.Post{ID: id, Title: "Test Post", AuthorID: &authorID}, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewPostHandler(repo, nil, nil)
|
||||
|
||||
updateData := map[string]string{
|
||||
"title": "Updated Title",
|
||||
"content": "Updated content",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(updateData)
|
||||
request := httptest.NewRequest("PUT", "/api/posts/1", bytes.NewBuffer(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request = request.WithContext(context.WithValue(request.Context(), middleware.UserIDKey, uint(1)))
|
||||
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", "1")
|
||||
request = request.WithContext(context.WithValue(request.Context(), chi.RouteCtxKey, routeCtx))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.UpdatePost(recorder, request)
|
||||
|
||||
if recorder.Result().StatusCode != http.StatusForbidden {
|
||||
t.Errorf("Expected status 403, got %d. Users should not be able to edit other users' posts", recorder.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageHandler_PasswordResetValidation(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
expectedError bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "valid password",
|
||||
password: "Password123!",
|
||||
expectedError: false,
|
||||
description: "Valid passwords should pass validation",
|
||||
},
|
||||
{
|
||||
name: "password without special chars",
|
||||
password: "Password123",
|
||||
expectedError: true,
|
||||
description: "Passwords without special characters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password too short",
|
||||
password: "Pass1!",
|
||||
expectedError: true,
|
||||
description: "Passwords shorter than 8 characters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password without letters",
|
||||
password: "12345678!",
|
||||
expectedError: true,
|
||||
description: "Passwords without letters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password without numbers",
|
||||
password: "Password!",
|
||||
expectedError: true,
|
||||
description: "Passwords without numbers should be rejected",
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
password: "",
|
||||
expectedError: true,
|
||||
description: "Empty passwords should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password too long",
|
||||
password: string(make([]byte, 129)),
|
||||
expectedError: true,
|
||||
description: "Passwords longer than 128 characters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "valid password with unicode",
|
||||
password: "Pássw0rd123!",
|
||||
expectedError: false,
|
||||
description: "Valid passwords with unicode should pass validation",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validation.ValidatePassword(tt.password)
|
||||
|
||||
if tt.expectedError && err == nil {
|
||||
t.Errorf("ValidatePassword(%q) expected error, got nil. %s", tt.password, tt.description)
|
||||
}
|
||||
if !tt.expectedError && err != nil {
|
||||
t.Errorf("ValidatePassword(%q) unexpected error: %v. %s", tt.password, err, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"goyco/internal/dto"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/validation"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
authService AuthServiceInterface
|
||||
}
|
||||
|
||||
func NewUserHandler(userRepo repositories.UserRepository, authService AuthServiceInterface) *UserHandler {
|
||||
return &UserHandler{
|
||||
userRepo: userRepo,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
type UserResponse = CommonResponse
|
||||
|
||||
// @Summary List users
|
||||
// @Description Retrieve a paginated list of users
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param limit query int false "Number of users to return" default(20)
|
||||
// @Param offset query int false "Number of users to skip" default(0)
|
||||
// @Success 200 {object} UserResponse "Users retrieved successfully"
|
||||
// @Failure 401 {object} UserResponse "Authentication required"
|
||||
// @Failure 500 {object} UserResponse "Internal server error"
|
||||
// @Router /users [get]
|
||||
func (h *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
limit, offset := parsePagination(r)
|
||||
|
||||
users, err := h.userRepo.GetAll(limit, offset)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Failed to fetch users", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
userDTOs := dto.ToSanitizedUserDTOs(users)
|
||||
|
||||
SendSuccessResponse(w, "Users retrieved successfully", map[string]any{
|
||||
"users": userDTOs,
|
||||
"count": len(userDTOs),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Get user
|
||||
// @Description Retrieve a specific user by ID
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 200 {object} UserResponse "User retrieved successfully"
|
||||
// @Failure 400 {object} UserResponse "Invalid user ID"
|
||||
// @Failure 401 {object} UserResponse "Authentication required"
|
||||
// @Failure 404 {object} UserResponse "User not found"
|
||||
// @Failure 500 {object} UserResponse "Internal server error"
|
||||
// @Router /users/{id} [get]
|
||||
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := ParseUintParam(w, r, "id", "User")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userRepo.GetByID(userID)
|
||||
if !HandleRepoError(w, err, "User") {
|
||||
return
|
||||
}
|
||||
|
||||
userDTO := dto.ToSanitizedUserDTO(user)
|
||||
|
||||
SendSuccessResponse(w, "User retrieved successfully", userDTO)
|
||||
}
|
||||
|
||||
// @Summary Create user
|
||||
// @Description Create a new user account
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body RegisterRequest true "User data"
|
||||
// @Success 201 {object} UserResponse "User created successfully"
|
||||
// @Failure 400 {object} UserResponse "Invalid request data or validation failed"
|
||||
// @Failure 401 {object} UserResponse "Authentication required"
|
||||
// @Failure 409 {object} UserResponse "Username or email already exists"
|
||||
// @Failure 500 {object} UserResponse "Internal server error"
|
||||
// @Router /users [post]
|
||||
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidateUsername(req.Username); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidateEmail(req.Email); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validation.ValidatePassword(req.Password); err != nil {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.authService.Register(req.Username, req.Email, req.Password)
|
||||
if err != nil {
|
||||
var validationErr *validation.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !HandleServiceError(w, err, "Failed to create user", http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
SendCreatedResponse(w, "User created successfully. Verification email sent.", map[string]any{
|
||||
"user": result.User,
|
||||
"verification_sent": result.VerificationSent,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Get user posts
|
||||
// @Description Retrieve posts created by a specific user
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "User ID"
|
||||
// @Param limit query int false "Number of posts to return" default(20)
|
||||
// @Param offset query int false "Number of posts to skip" default(0)
|
||||
// @Success 200 {object} UserResponse "User posts retrieved successfully"
|
||||
// @Failure 400 {object} UserResponse "Invalid user ID or pagination parameters"
|
||||
// @Failure 401 {object} UserResponse "Authentication required"
|
||||
// @Failure 500 {object} UserResponse "Internal server error"
|
||||
// @Router /users/{id}/posts [get]
|
||||
func (h *UserHandler) GetUserPosts(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := ParseUintParam(w, r, "id", "User")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
limit, offset := parsePagination(r)
|
||||
|
||||
posts, err := h.userRepo.GetPosts(userID, limit, offset)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Failed to fetch user posts", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
postDTOs := dto.ToPostDTOs(posts)
|
||||
SendSuccessResponse(w, "User posts retrieved successfully", map[string]any{
|
||||
"posts": postDTOs,
|
||||
"count": len(postDTOs),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *UserHandler) MountRoutes(r chi.Router, config RouteModuleConfig) {
|
||||
protected := r
|
||||
if config.AuthMiddleware != nil {
|
||||
protected = r.With(config.AuthMiddleware)
|
||||
}
|
||||
if config.GeneralRateLimit != nil {
|
||||
protected = config.GeneralRateLimit(protected)
|
||||
}
|
||||
|
||||
protected.Get("/users", h.GetUsers)
|
||||
protected.Post("/users", h.CreateUser)
|
||||
protected.Get("/users/{id}", h.GetUser)
|
||||
protected.Get("/users/{id}/posts", h.GetUserPosts)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"goyco/internal/config"
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func newUserHandler(repo repositories.UserRepository) *UserHandler {
|
||||
return newUserHandlerWithSender(repo, &testutils.EmailSenderStub{})
|
||||
}
|
||||
|
||||
func newUserHandlerWithSender(repo repositories.UserRepository, sender services.EmailSender) *UserHandler {
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{Secret: "secret", Expiration: 1},
|
||||
App: config.AppConfig{BaseURL: "https://test.example.com"},
|
||||
}
|
||||
mockRefreshRepo := &mockRefreshTokenRepository{}
|
||||
authService, err := services.NewAuthFacadeForTest(cfg, repo, nil, nil, mockRefreshRepo, sender)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to create auth service: %v", err))
|
||||
}
|
||||
return NewUserHandler(repo, authService)
|
||||
}
|
||||
|
||||
func TestUserHandlerGetUsers(t *testing.T) {
|
||||
var limit, offset int
|
||||
repo := testutils.NewUserRepositoryStub()
|
||||
repo.GetAllFn = func(l, o int) ([]database.User, error) {
|
||||
limit, offset = l, o
|
||||
return []database.User{{ID: 1}}, nil
|
||||
}
|
||||
|
||||
handler := newUserHandler(repo)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/users?limit=5&offset=2", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.GetUsers(recorder, request)
|
||||
|
||||
if limit != 5 || offset != 2 {
|
||||
t.Fatalf("expected limit=5 offset=2, got %d %d", limit, offset)
|
||||
}
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
}
|
||||
|
||||
func TestUserHandlerGetUser(t *testing.T) {
|
||||
repo := testutils.NewUserRepositoryStub()
|
||||
handler := newUserHandler(repo)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/users/1", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.GetUser(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/users/abc", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "abc"})
|
||||
recorder = httptest.NewRecorder()
|
||||
handler.GetUser(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
||||
|
||||
repo.GetByIDFn = func(uint) (*database.User, error) { return nil, gorm.ErrRecordNotFound }
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/users/1", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
recorder = httptest.NewRecorder()
|
||||
handler.GetUser(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusNotFound)
|
||||
|
||||
repo.GetByIDFn = func(id uint) (*database.User, error) {
|
||||
return &database.User{ID: id, Username: "user"}, nil
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/users/1", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
recorder = httptest.NewRecorder()
|
||||
handler.GetUser(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
}
|
||||
|
||||
func TestUserHandlerCreateUser(t *testing.T) {
|
||||
repo := testutils.NewUserRepositoryStub()
|
||||
repo.CreateFn = func(u *database.User) error {
|
||||
u.ID = 10
|
||||
return nil
|
||||
}
|
||||
sent := false
|
||||
handler := newUserHandlerWithSender(repo, &testutils.EmailSenderStub{SendFn: func(to, subject, body string) error {
|
||||
sent = true
|
||||
if to != "user@example.com" {
|
||||
t.Fatalf("expected email to user@example.com, got %q", to)
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewBufferString(`{"username":"user","email":"user@example.com","password":"Password123!"}`))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.CreateUser(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusCreated)
|
||||
|
||||
var resp UserResponse
|
||||
_ = json.NewDecoder(recorder.Body).Decode(&resp)
|
||||
data := resp.Data.(map[string]any)
|
||||
if !resp.Success {
|
||||
t.Fatalf("expected success response")
|
||||
}
|
||||
if v, ok := data["verification_sent"].(bool); !ok || !v {
|
||||
t.Fatalf("expected verification_sent true, got %+v", data["verification_sent"])
|
||||
}
|
||||
userData := data["user"].(map[string]any)
|
||||
if _, ok := userData["password"]; ok {
|
||||
t.Fatalf("expected password field to be omitted, got %+v", userData)
|
||||
}
|
||||
if !sent {
|
||||
t.Fatalf("expected verification email to be sent")
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewBufferString("invalid"))
|
||||
handler.CreateUser(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid json, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewBufferString(`{"username":"","email":"","password":""}`))
|
||||
handler.CreateUser(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing fields, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
repo.GetByUsernameFn = func(string) (*database.User, error) {
|
||||
return &database.User{ID: 1}, nil
|
||||
}
|
||||
handler = newUserHandler(repo)
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewBufferString(`{"username":"user","email":"user@example.com","password":"Password123!"}`))
|
||||
handler.CreateUser(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusConflict)
|
||||
}
|
||||
|
||||
func TestUserHandlerGetUserPosts(t *testing.T) {
|
||||
repo := testutils.NewUserRepositoryStub()
|
||||
repo.GetPostsFn = func(userID uint, limit, offset int) ([]database.Post, error) {
|
||||
return []database.Post{{ID: 1, AuthorID: &userID}}, nil
|
||||
}
|
||||
handler := newUserHandler(repo)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/users/1/posts?limit=2&offset=1", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.GetUserPosts(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
repo.GetPostsFn = func(uint, int, int) ([]database.Post, error) {
|
||||
return nil, gorm.ErrInvalidValue
|
||||
}
|
||||
recorder = httptest.NewRecorder()
|
||||
handler.GetUserPosts(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func TestUserHandlerDataSanitization(t *testing.T) {
|
||||
repo := testutils.NewUserRepositoryStub()
|
||||
repo.GetAllFn = func(l, o int) ([]database.User, error) {
|
||||
users := []database.User{
|
||||
{
|
||||
ID: 1,
|
||||
Username: "user1",
|
||||
Email: "user1@example.com",
|
||||
Password: "hashedpassword",
|
||||
EmailVerified: true,
|
||||
EmailVerifiedAt: &[]time.Time{time.Now()}[0],
|
||||
EmailVerificationToken: "secret-token",
|
||||
PasswordResetToken: "reset-token",
|
||||
Locked: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Username: "user2",
|
||||
Email: "user2@example.com",
|
||||
Password: "another-hashed-password",
|
||||
EmailVerified: false,
|
||||
EmailVerificationToken: "another-secret-token",
|
||||
Locked: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
handler := newUserHandler(repo)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/users", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.GetUsers(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
var response map[string]any
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := response["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data field in response")
|
||||
}
|
||||
|
||||
users, ok := data["users"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected users field in data")
|
||||
}
|
||||
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("expected 2 users, got %d", len(users))
|
||||
}
|
||||
|
||||
for i, userInterface := range users {
|
||||
user, ok := userInterface.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected user %d to be a map", i)
|
||||
}
|
||||
|
||||
expectedFields := []string{"id", "username", "created_at", "updated_at"}
|
||||
for _, field := range expectedFields {
|
||||
if _, exists := user[field]; !exists {
|
||||
t.Errorf("expected field %s to be present in user %d", field, i)
|
||||
}
|
||||
}
|
||||
|
||||
sensitiveFields := []string{"email", "password", "email_verified", "email_verified_at",
|
||||
"email_verification_token", "password_reset_token", "locked", "deleted_at"}
|
||||
for _, field := range sensitiveFields {
|
||||
if _, exists := user[field]; exists {
|
||||
t.Errorf("sensitive field %s should not be present in user %d", field, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_PasswordValidation(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
expectedStatus int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "valid password",
|
||||
password: "Password123!",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Valid passwords should be accepted",
|
||||
},
|
||||
{
|
||||
name: "password without special chars",
|
||||
password: "Password123",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Passwords without special characters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password too short",
|
||||
password: "Pass1!",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Passwords shorter than 8 characters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password without letters",
|
||||
password: "12345678!",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Passwords without letters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password without numbers",
|
||||
password: "Password!",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Passwords without numbers should be rejected",
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
password: "",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Empty passwords should be rejected",
|
||||
},
|
||||
{
|
||||
name: "password too long",
|
||||
password: string(make([]byte, 129)),
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Passwords longer than 128 characters should be rejected",
|
||||
},
|
||||
{
|
||||
name: "valid password with unicode",
|
||||
password: "Pássw0rd123!",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Valid passwords with unicode should be accepted",
|
||||
},
|
||||
{
|
||||
name: "valid password with underscore",
|
||||
password: "Password123_",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Valid passwords with underscore should be accepted",
|
||||
},
|
||||
{
|
||||
name: "valid password with hyphen",
|
||||
password: "Password123-",
|
||||
expectedStatus: http.StatusCreated,
|
||||
description: "Valid passwords with hyphen should be accepted",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := testutils.NewUserRepositoryStub()
|
||||
repo.CreateFn = func(user *database.User) error {
|
||||
return nil
|
||||
}
|
||||
repo.GetByUsernameFn = func(username string) (*database.User, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
repo.GetByEmailFn = func(email string) (*database.User, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{Secret: "secret", Expiration: 1},
|
||||
App: config.AppConfig{BaseURL: "https://test.example.com"},
|
||||
}
|
||||
emailSender := &testutils.MockEmailSender{}
|
||||
mockRefreshRepo := &mockRefreshTokenRepository{}
|
||||
authService, err := services.NewAuthFacadeForTest(cfg, repo, nil, nil, mockRefreshRepo, emailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
handler := NewUserHandler(repo, authService)
|
||||
|
||||
requestBody := fmt.Sprintf(`{"username":"testuser","email":"test@example.com","password":"%s"}`, tt.password)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewBufferString(requestBody))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.CreateUser(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, tt.expectedStatus)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/services"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
// @tag.name votes
|
||||
// @tag.description Voting system endpoints. All votes are handled through the same API with identical behavior.
|
||||
|
||||
// @tag.name posts
|
||||
// @tag.description Post management endpoints with integrated vote statistics.
|
||||
|
||||
// @tag.name auth
|
||||
// @tag.description Authentication and user management endpoints.
|
||||
|
||||
// @tag.name users
|
||||
// @tag.description User management endpoints.
|
||||
|
||||
// @tag.name api
|
||||
// @tag.description API information and system metrics.
|
||||
|
||||
type VoteHandler struct {
|
||||
voteService *services.VoteService
|
||||
}
|
||||
|
||||
func NewVoteHandler(voteService *services.VoteService) *VoteHandler {
|
||||
return &VoteHandler{
|
||||
voteService: voteService,
|
||||
}
|
||||
}
|
||||
|
||||
// @Description Vote request with type field. All votes are handled the same way.
|
||||
type VoteRequest struct {
|
||||
Type string `json:"type" example:"up" enums:"up,down,none" description:"Vote type: 'up' for upvote, 'down' for downvote, 'none' to remove vote"`
|
||||
}
|
||||
|
||||
type VoteResponse = CommonResponse
|
||||
|
||||
// @Summary Cast a vote on a post
|
||||
// @Description Vote on a post (upvote, downvote, or remove vote). Authentication is required; the vote is performed on behalf of the current user.
|
||||
// @Description
|
||||
// @Description **Vote Types:**
|
||||
// @Description - `up`: Upvote the post
|
||||
// @Description - `down`: Downvote the post
|
||||
// @Description - `none`: Remove existing vote
|
||||
// @Description
|
||||
// @Description **Response includes:**
|
||||
// @Description - Updated post vote counts (up_votes, down_votes, score)
|
||||
// @Description - Success message
|
||||
// @Tags votes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Post ID"
|
||||
// @Param request body VoteRequest true "Vote data (type: 'up', 'down', or 'none' to remove)"
|
||||
// @Success 200 {object} VoteResponse "Vote cast successfully with updated post statistics"
|
||||
// @Failure 401 {object} VoteResponse "Authentication required"
|
||||
// @Failure 400 {object} VoteResponse "Invalid request data or vote type"
|
||||
// @Failure 404 {object} VoteResponse "Post not found"
|
||||
// @Failure 500 {object} VoteResponse "Internal server error"
|
||||
// @Example 200 {"success": true, "message": "Vote cast successfully", "data": {"post_id": 1, "type": "up", "up_votes": 5, "down_votes": 2, "score": 3, "is_anonymous": false}}
|
||||
// @Example 400 {"success": false, "error": "Invalid vote type. Must be 'up', 'down', or 'none'"}
|
||||
// @Router /posts/{id}/vote [post]
|
||||
func (h *VoteHandler) CastVote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
postID, ok := ParseUintParam(w, r, "id", "Post")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req VoteRequest
|
||||
if !DecodeJSONRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
var voteType database.VoteType
|
||||
switch req.Type {
|
||||
case "up":
|
||||
voteType = database.VoteUp
|
||||
case "down":
|
||||
voteType = database.VoteDown
|
||||
case "none":
|
||||
voteType = database.VoteNone
|
||||
default:
|
||||
SendErrorResponse(w, "Invalid vote type. Must be 'up', 'down', or 'none'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ipAddress := GetClientIP(r)
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
serviceReq := services.VoteRequest{
|
||||
UserID: userID,
|
||||
PostID: postID,
|
||||
Type: voteType,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
|
||||
response, err := h.voteService.CastVote(serviceReq)
|
||||
if err != nil {
|
||||
if err.Error() == "post not found" {
|
||||
SendErrorResponse(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err.Error() == "post ID is required" || err.Error() == "invalid vote type" {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
SendErrorResponse(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Vote cast successfully", response)
|
||||
}
|
||||
|
||||
// @Summary Remove a vote
|
||||
// @Description Remove a vote from a post for the authenticated user. This is equivalent to casting a vote with type 'none'.
|
||||
// @Tags votes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Post ID"
|
||||
// @Success 200 {object} VoteResponse "Vote removed successfully with updated post statistics"
|
||||
// @Failure 401 {object} VoteResponse "Authentication required"
|
||||
// @Failure 400 {object} VoteResponse "Invalid post ID"
|
||||
// @Failure 404 {object} VoteResponse "Post not found"
|
||||
// @Failure 500 {object} VoteResponse "Internal server error"
|
||||
// @Router /posts/{id}/vote [delete]
|
||||
func (h *VoteHandler) RemoveVote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
postID, ok := ParseUintParam(w, r, "id", "Post")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ipAddress := GetClientIP(r)
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
serviceReq := services.VoteRequest{
|
||||
UserID: userID,
|
||||
PostID: postID,
|
||||
Type: database.VoteNone,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
|
||||
response, err := h.voteService.CastVote(serviceReq)
|
||||
if err != nil {
|
||||
if err.Error() == "post not found" {
|
||||
SendErrorResponse(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err.Error() == "post ID is required" {
|
||||
SendErrorResponse(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
SendErrorResponse(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Vote removed successfully", response)
|
||||
}
|
||||
|
||||
// @Summary Get current user's vote
|
||||
// @Description Retrieve the current user's vote for a specific post. Requires authentication and returns the vote type if it exists.
|
||||
// @Description
|
||||
// @Description **Response:**
|
||||
// @Description - If vote exists: Returns vote details with contextual metadata (including `is_anonymous`)
|
||||
// @Description - If no vote: Returns success with null vote data and metadata
|
||||
// @Tags votes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Post ID"
|
||||
// @Success 200 {object} VoteResponse "Vote retrieved successfully"
|
||||
// @Success 200 {object} VoteResponse "No vote found for this user/post combination"
|
||||
// @Failure 401 {object} VoteResponse "Authentication required"
|
||||
// @Failure 400 {object} VoteResponse "Invalid post ID"
|
||||
// @Failure 500 {object} VoteResponse "Internal server error"
|
||||
// @Example 200 {"success": true, "message": "Vote retrieved successfully", "data": {"has_vote": true, "vote": {"type": "up", "user_id": 123}, "is_anonymous": false}}
|
||||
// @Example 200 {"success": true, "message": "No vote found", "data": {"has_vote": false, "vote": null, "is_anonymous": false}}
|
||||
// @Router /posts/{id}/vote [get]
|
||||
func (h *VoteHandler) GetUserVote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := RequireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
postID, ok := ParseUintParam(w, r, "id", "Post")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ipAddress := GetClientIP(r)
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
vote, err := h.voteService.GetUserVote(userID, postID, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
if err.Error() == "record not found" {
|
||||
SendSuccessResponse(w, "No vote found", map[string]any{
|
||||
"has_vote": false,
|
||||
"vote": nil,
|
||||
"is_anonymous": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
SendErrorResponse(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Vote retrieved successfully", map[string]any{
|
||||
"has_vote": true,
|
||||
"vote": vote,
|
||||
"is_anonymous": false,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Get post votes
|
||||
// @Description Retrieve all votes for a specific post. Returns all votes in a single format.
|
||||
// @Description
|
||||
// @Description **Authentication Required:** Yes (Bearer token)
|
||||
// @Description
|
||||
// @Description **Response includes:**
|
||||
// @Description - Array of all votes
|
||||
// @Description - Total vote count
|
||||
// @Description - Each vote includes type and unauthenticated status
|
||||
// @Tags votes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Post ID"
|
||||
// @Success 200 {object} VoteResponse "Votes retrieved successfully with count"
|
||||
// @Failure 400 {object} VoteResponse "Invalid post ID"
|
||||
// @Failure 401 {object} VoteResponse "Authentication required"
|
||||
// @Failure 500 {object} VoteResponse "Internal server error"
|
||||
// @Example 200 {"success": true, "message": "Votes retrieved successfully", "data": {"votes": [{"type": "up", "user_id": 123}, {"type": "down", "vote_hash": "abc123"}], "count": 2}}
|
||||
// @Router /posts/{id}/votes [get]
|
||||
func (h *VoteHandler) GetPostVotes(w http.ResponseWriter, r *http.Request) {
|
||||
postID, ok := ParseUintParam(w, r, "id", "Post")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
votes, err := h.voteService.GetPostVotes(postID)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
allVotes := make([]any, 0, len(votes))
|
||||
for _, vote := range votes {
|
||||
allVotes = append(allVotes, vote)
|
||||
}
|
||||
|
||||
SendSuccessResponse(w, "Votes retrieved successfully", map[string]any{
|
||||
"votes": allVotes,
|
||||
"count": len(allVotes),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *VoteHandler) MountRoutes(r chi.Router, config RouteModuleConfig) {
|
||||
protected := r
|
||||
if config.AuthMiddleware != nil {
|
||||
protected = r.With(config.AuthMiddleware)
|
||||
}
|
||||
if config.GeneralRateLimit != nil {
|
||||
protected = config.GeneralRateLimit(protected)
|
||||
}
|
||||
|
||||
protected.Post("/posts/{id}/vote", h.CastVote)
|
||||
protected.Delete("/posts/{id}/vote", h.RemoveVote)
|
||||
protected.Get("/posts/{id}/vote", h.GetUserVote)
|
||||
protected.Get("/posts/{id}/votes", h.GetPostVotes)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func newVoteHandlerWithRepos() *VoteHandler {
|
||||
handler, _, _ := newVoteHandlerWithReposRefs()
|
||||
return handler
|
||||
}
|
||||
|
||||
func newVoteHandlerWithReposRefs() (*VoteHandler, *testutils.MockVoteRepository, map[uint]*database.Post) {
|
||||
voteRepo := testutils.NewMockVoteRepository()
|
||||
posts := map[uint]*database.Post{
|
||||
1: {ID: 1},
|
||||
}
|
||||
postRepo := testutils.NewPostRepositoryStub()
|
||||
postRepo.GetByIDFn = func(id uint) (*database.Post, error) {
|
||||
if post, ok := posts[id]; ok {
|
||||
copy := *post
|
||||
return ©, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
postRepo.UpdateFn = func(post *database.Post) error {
|
||||
copy := *post
|
||||
posts[post.ID] = ©
|
||||
return nil
|
||||
}
|
||||
postRepo.DeleteFn = func(id uint) error {
|
||||
if _, ok := posts[id]; !ok {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
delete(posts, id)
|
||||
return nil
|
||||
}
|
||||
postRepo.CreateFn = func(post *database.Post) error {
|
||||
copy := *post
|
||||
posts[post.ID] = ©
|
||||
return nil
|
||||
}
|
||||
service := services.NewVoteService(voteRepo, postRepo, nil)
|
||||
return NewVoteHandler(service), voteRepo, posts
|
||||
}
|
||||
|
||||
func TestVoteHandlerCastVote(t *testing.T) {
|
||||
handler := newVoteHandlerWithRepos()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
handler.CastVote(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusUnauthorized)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/abc/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "abc"})
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`invalid`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid json, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"maybe"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid vote type, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"down"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(2))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for successful down vote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"none"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(3))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for successful none vote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoteHandlerCastVotePostNotFound(t *testing.T) {
|
||||
handler, _, posts := newVoteHandlerWithReposRefs()
|
||||
delete(posts, 1)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.CastVote(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestVoteHandlerRemoveVote(t *testing.T) {
|
||||
handler := newVoteHandlerWithRepos()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodDelete, "/api/posts/1/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
handler.RemoveVote(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusUnauthorized)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodDelete, "/api/posts/abc/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "abc"})
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.RemoveVote(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodDelete, "/api/posts/1/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.RemoveVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for removing non-existent vote (idempotent), got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for creating vote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodDelete, "/api/posts/1/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.RemoveVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 when removing vote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoteHandlerRemoveVotePostNotFound(t *testing.T) {
|
||||
handler, _, posts := newVoteHandlerWithReposRefs()
|
||||
delete(posts, 1)
|
||||
|
||||
request := httptest.NewRequest(http.MethodDelete, "/api/posts/1/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.RemoveVote(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestVoteHandlerRemoveVoteUnexpectedError(t *testing.T) {
|
||||
handler, voteRepo, _ := newVoteHandlerWithReposRefs()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.CastVote(recorder, request)
|
||||
|
||||
voteRepo.DeleteErr = fmt.Errorf("database unavailable")
|
||||
|
||||
request = httptest.NewRequest(http.MethodDelete, "/api/posts/1/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
recorder = httptest.NewRecorder()
|
||||
|
||||
handler.RemoveVote(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func TestVoteHandlerGetUserVote(t *testing.T) {
|
||||
handler := newVoteHandlerWithRepos()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/posts/1/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
handler.GetUserVote(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusUnauthorized)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/abc/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "abc"})
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.GetUserVote(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/1/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.GetUserVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 when vote missing, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
var resp VoteResponse
|
||||
_ = json.NewDecoder(recorder.Body).Decode(&resp)
|
||||
data := resp.Data.(map[string]any)
|
||||
if data["has_vote"].(bool) {
|
||||
t.Fatalf("expected has_vote false, got true")
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for creating vote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/1/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.GetUserVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 when vote exists, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
_ = json.NewDecoder(recorder.Body).Decode(&resp)
|
||||
data = resp.Data.(map[string]any)
|
||||
if !data["has_vote"].(bool) {
|
||||
t.Fatalf("expected has_vote true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoteHandlerGetPostVotes(t *testing.T) {
|
||||
handler := newVoteHandlerWithRepos()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/posts/abc/votes", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "abc"})
|
||||
handler.GetPostVotes(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/1/votes", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
handler.GetPostVotes(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for empty votes, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for creating vote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"down"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(2))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for creating vote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/1/votes", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
handler.GetPostVotes(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
var resp VoteResponse
|
||||
_ = json.NewDecoder(recorder.Body).Decode(&resp)
|
||||
data := resp.Data.(map[string]any)
|
||||
votes := data["votes"].([]any)
|
||||
if len(votes) != 2 {
|
||||
t.Fatalf("expected 2 votes, got %d", len(votes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoteFlowRegression(t *testing.T) {
|
||||
handler := newVoteHandlerWithRepos()
|
||||
|
||||
t.Run("CompleteVoteLifecycle", func(t *testing.T) {
|
||||
userID := uint(1)
|
||||
postID := "1"
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": postID})
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, userID)
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/1/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": postID})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, userID)
|
||||
request = request.WithContext(ctx)
|
||||
handler.GetUserVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for getting vote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"down"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": postID})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, userID)
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for changing to downvote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"none"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": postID})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, userID)
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for removing vote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/1/vote", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": postID})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, userID)
|
||||
request = request.WithContext(ctx)
|
||||
handler.GetUserVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for getting removed vote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
var resp VoteResponse
|
||||
_ = json.NewDecoder(recorder.Body).Decode(&resp)
|
||||
data := resp.Data.(map[string]any)
|
||||
if data["has_vote"].(bool) {
|
||||
t.Fatalf("expected has_vote false after removal, got true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MultipleUsersVoting", func(t *testing.T) {
|
||||
postID := "1"
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": postID})
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for user 1 upvote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"down"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": postID})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(2))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for user 2 downvote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"up"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": postID})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(3))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for user 3 upvote, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/posts/1/votes", nil)
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": postID})
|
||||
handler.GetPostVotes(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for getting all votes, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
var resp VoteResponse
|
||||
_ = json.NewDecoder(recorder.Body).Decode(&resp)
|
||||
data := resp.Data.(map[string]any)
|
||||
votes := data["votes"].([]any)
|
||||
if len(votes) != 3 {
|
||||
t.Fatalf("expected 3 votes, got %d", len(votes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ErrorHandlingEdgeCases", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(``))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx := context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusBadRequest)
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing type field, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/posts/1/vote", bytes.NewBufferString(`{"type":"invalid"}`))
|
||||
request = testutils.WithURLParams(request, map[string]string{"id": "1"})
|
||||
ctx = context.WithValue(request.Context(), middleware.UserIDKey, uint(1))
|
||||
request = request.WithContext(ctx)
|
||||
handler.CastVote(recorder, request)
|
||||
if recorder.Result().StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid vote type, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/handlers"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/server"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func setupCachingTestContext(t *testing.T) *testContext {
|
||||
t.Helper()
|
||||
suite := testutils.NewServiceSuite(t)
|
||||
|
||||
authService, err := services.NewAuthFacadeForTest(testutils.AppTestConfig, suite.UserRepo, suite.PostRepo, suite.DeletionRepo, suite.RefreshTokenRepo, suite.EmailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
|
||||
voteService := services.NewVoteService(suite.VoteRepo, suite.PostRepo, suite.DB)
|
||||
metadataService := services.NewURLMetadataService()
|
||||
|
||||
authHandler := handlers.NewAuthHandler(authService, suite.UserRepo)
|
||||
postHandler := handlers.NewPostHandler(suite.PostRepo, metadataService, voteService)
|
||||
voteHandler := handlers.NewVoteHandler(voteService)
|
||||
userHandler := handlers.NewUserHandler(suite.UserRepo, authService)
|
||||
apiHandler := handlers.NewAPIHandlerWithMonitoring(testutils.AppTestConfig, suite.PostRepo, suite.UserRepo, voteService, suite.DB, middleware.NewInMemoryDBMonitor())
|
||||
|
||||
staticDir := t.TempDir()
|
||||
|
||||
router := server.NewRouter(server.RouterConfig{
|
||||
AuthHandler: authHandler,
|
||||
PostHandler: postHandler,
|
||||
VoteHandler: voteHandler,
|
||||
UserHandler: userHandler,
|
||||
APIHandler: apiHandler,
|
||||
AuthService: authService,
|
||||
PageHandler: nil,
|
||||
StaticDir: staticDir,
|
||||
Debug: false,
|
||||
DisableCache: false,
|
||||
DisableCompression: false,
|
||||
DBMonitor: middleware.NewInMemoryDBMonitor(),
|
||||
RateLimitConfig: testutils.AppTestConfig.RateLimit,
|
||||
})
|
||||
|
||||
return &testContext{
|
||||
Router: router,
|
||||
Suite: suite,
|
||||
AuthService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Caching(t *testing.T) {
|
||||
ctx := setupCachingTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("Cache_Hit_On_Repeated_Requests", func(t *testing.T) {
|
||||
req1 := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec1, req1)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
req2 := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
if rec1.Code != rec2.Code {
|
||||
t.Error("Cached responses should have same status code")
|
||||
}
|
||||
|
||||
if rec1.Body.String() != rec2.Body.String() {
|
||||
t.Error("Cached responses should have same body")
|
||||
}
|
||||
|
||||
if rec2.Header().Get("X-Cache") != "HIT" {
|
||||
t.Log("Cache may not be enabled for this path or response may not be cacheable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cache_Invalidation_On_POST", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "cache_post_user", "cache_post@example.com")
|
||||
|
||||
req1 := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec1, req1)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
postBody := map[string]string{
|
||||
"title": "Cache Test Post",
|
||||
"url": "https://example.com/cache-test",
|
||||
"content": "Test content",
|
||||
}
|
||||
body, _ := json.Marshal(postBody)
|
||||
req2 := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(body))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req2 = testutils.WithUserContext(req2, middleware.UserIDKey, user.User.ID)
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
req3 := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec3 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec3, req3)
|
||||
|
||||
if rec1.Body.String() == rec3.Body.String() && rec1.Code == http.StatusOK && rec3.Code == http.StatusOK {
|
||||
t.Log("Cache invalidation may not be working or cache may not be enabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cache_Headers_Present", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("Cache-Control") == "" && rec.Header().Get("X-Cache") == "" {
|
||||
t.Log("Cache headers may not be present for all responses")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cache_Invalidation_On_DELETE", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "cache_delete_user", "cache_delete@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Cache Delete Post", "https://example.com/cache-delete")
|
||||
|
||||
req1 := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec1, req1)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
req2 := httptest.NewRequest("DELETE", "/api/posts/"+fmt.Sprintf("%d", post.ID), nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req2 = testutils.WithUserContext(req2, middleware.UserIDKey, user.User.ID)
|
||||
req2 = testutils.WithURLParams(req2, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
req3 := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec3 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec3, req3)
|
||||
|
||||
if rec1.Body.String() == rec3.Body.String() && rec1.Code == http.StatusOK && rec3.Code == http.StatusOK {
|
||||
t.Log("Cache invalidation may not be working or cache may not be enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_CompleteAPIEndpoints(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("Auth_Logout_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "logout_user", "logout@example.com")
|
||||
|
||||
reqBody := map[string]string{}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/logout", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Auth_Revoke_Token_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "revoke_user", "revoke@example.com")
|
||||
|
||||
loginResult, err := ctx.AuthService.Login("revoke_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
reqBody := map[string]string{
|
||||
"refresh_token": loginResult.RefreshToken,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/revoke", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Auth_Revoke_All_Tokens_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "revoke_all_user", "revoke_all@example.com")
|
||||
|
||||
reqBody := map[string]string{}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/revoke-all", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Auth_Resend_Verification_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
reqBody := map[string]string{
|
||||
"email": "resend@example.com",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/resend-verification", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("Auth_Confirm_Email_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "confirm_api_user", "confirm_api@example.com")
|
||||
|
||||
token := ctx.Suite.EmailSender.VerificationToken()
|
||||
if token == "" {
|
||||
token = "test-token"
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/auth/confirm?token="+url.QueryEscape(token), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("Auth_Update_Email_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "update_email_api_user", "update_email_api@example.com")
|
||||
|
||||
reqBody := map[string]string{
|
||||
"email": "newemail@example.com",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("PUT", "/api/auth/email", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if email, ok := data["email"].(string); ok && email != "newemail@example.com" {
|
||||
t.Errorf("Expected email to be updated, got %s", email)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Auth_Update_Username_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "update_username_api_user", "update_username_api@example.com")
|
||||
|
||||
reqBody := map[string]string{
|
||||
"username": "new_username",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("PUT", "/api/auth/username", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if username, ok := data["username"].(string); ok && username != "new_username" {
|
||||
t.Errorf("Expected username to be updated, got %s", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Users_List_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "users_list_user", "users_list@example.com")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/users", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if _, exists := data["users"]; !exists {
|
||||
t.Error("Expected users in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Users_Get_By_ID_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "users_get_user", "users_get@example.com")
|
||||
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/users/%d", user.User.ID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", user.User.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if userData, ok := data["user"].(map[string]any); ok {
|
||||
if id, ok := userData["id"].(float64); ok && uint(id) != user.User.ID {
|
||||
t.Errorf("Expected user ID %d, got %.0f", user.User.ID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Users_Get_Posts_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "users_posts_user", "users_posts@example.com")
|
||||
|
||||
testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "User Posts Test", "https://example.com/user-posts")
|
||||
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/users/%d/posts", user.User.ID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", user.User.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if posts, ok := data["posts"].([]any); ok {
|
||||
if len(posts) == 0 {
|
||||
t.Error("Expected at least one post in response")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected posts array in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Users_Create_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "users_create_admin", "users_create_admin@example.com")
|
||||
|
||||
reqBody := map[string]string{
|
||||
"username": "created_user",
|
||||
"email": "created@example.com",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/users", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusCreated)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if _, exists := data["user"]; !exists {
|
||||
t.Error("Expected user in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Posts_Update_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "posts_update_user", "posts_update@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Update Test Post", "https://example.com/update-test")
|
||||
|
||||
reqBody := map[string]string{
|
||||
"title": "Updated Title",
|
||||
"content": "Updated content",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/api/posts/%d", post.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if postData, ok := data["post"].(map[string]any); ok {
|
||||
if title, ok := postData["title"].(string); ok && title != "Updated Title" {
|
||||
t.Errorf("Expected title 'Updated Title', got '%s'", title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Posts_Delete_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "posts_delete_user", "posts_delete@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Delete Test Post", "https://example.com/delete-test")
|
||||
|
||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/posts/%d", post.ID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
getReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d", post.ID), nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(getRec, getReq)
|
||||
assertStatus(t, getRec, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("Votes_Get_All_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "votes_get_all_user", "votes_get_all@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Votes Test Post", "https://example.com/votes-test")
|
||||
|
||||
voteBody := map[string]string{"type": "up"}
|
||||
voteBodyBytes, _ := json.Marshal(voteBody)
|
||||
voteReq := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(voteBodyBytes))
|
||||
voteReq.Header.Set("Content-Type", "application/json")
|
||||
voteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
voteReq = testutils.WithUserContext(voteReq, middleware.UserIDKey, user.User.ID)
|
||||
voteReq = testutils.WithURLParams(voteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
voteRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(voteRec, voteReq)
|
||||
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d/votes", post.ID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if votes, ok := data["votes"].([]any); ok {
|
||||
if len(votes) == 0 {
|
||||
t.Error("Expected at least one vote in response")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected votes array in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Votes_Remove_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "votes_remove_user", "votes_remove@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Vote Remove Test", "https://example.com/vote-remove")
|
||||
|
||||
voteBody := map[string]string{"type": "up"}
|
||||
voteBodyBytes, _ := json.Marshal(voteBody)
|
||||
voteReq := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(voteBodyBytes))
|
||||
voteReq.Header.Set("Content-Type", "application/json")
|
||||
voteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
voteReq = testutils.WithUserContext(voteReq, middleware.UserIDKey, user.User.ID)
|
||||
voteReq = testutils.WithURLParams(voteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
voteRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(voteRec, voteReq)
|
||||
|
||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/posts/%d/vote", post.ID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("API_Info_Endpoint", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if _, exists := data["endpoints"]; !exists {
|
||||
t.Error("Expected endpoints in API info")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Swagger_Documentation_Endpoint", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/swagger/index.html", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_Compression(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("Response_Compression_Gzip", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("Content-Encoding") == "gzip" {
|
||||
reader, err := gzip.NewReader(rec.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create gzip reader: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decompress: %v", err)
|
||||
}
|
||||
|
||||
if len(decompressed) == 0 {
|
||||
t.Error("Expected decompressed content")
|
||||
}
|
||||
} else {
|
||||
t.Log("Compression may not be applied to small responses")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Compression_Headers_Present", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("Vary") == "" {
|
||||
t.Log("Vary header may not always be present")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_StaticFiles(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("Robots_Txt_Served", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/robots.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "User-agent") {
|
||||
t.Error("Expected robots.txt content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Static_Files_Security_Headers", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/robots.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("X-Content-Type-Options") == "" {
|
||||
t.Log("Security headers may not be applied to all static files")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_URLMetadata(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("URL_Metadata_Fetch_On_Post_Creation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "metadata_user", "metadata@example.com")
|
||||
|
||||
ctx.Suite.TitleFetcher.SetTitle("Fetched Title")
|
||||
|
||||
postBody := map[string]string{
|
||||
"title": "Test Post",
|
||||
"url": "https://example.com/metadata-test",
|
||||
"content": "Test content",
|
||||
}
|
||||
body, _ := json.Marshal(postBody)
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusCreated)
|
||||
})
|
||||
|
||||
t.Run("URL_Metadata_Endpoint", func(t *testing.T) {
|
||||
ctx.Suite.TitleFetcher.SetTitle("Endpoint Title")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/posts/title?url=https://example.com/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if _, exists := data["title"]; !exists {
|
||||
t.Error("Expected title in metadata response")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_CrossComponentAuthorization(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("Post_Owner_Authorization", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
owner := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "post_owner", "post_owner@example.com")
|
||||
otherUser := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "post_other", "post_other@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, owner.User.ID, "Owner Post", "https://example.com/owner")
|
||||
|
||||
updateBody := map[string]string{
|
||||
"title": "Updated Title",
|
||||
"content": "Updated content",
|
||||
}
|
||||
body, _ := json.Marshal(updateBody)
|
||||
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/api/posts/%d", post.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+otherUser.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, otherUser.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusForbidden)
|
||||
|
||||
req = httptest.NewRequest("PUT", fmt.Sprintf("/api/posts/%d", post.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+owner.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, owner.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Post_Delete_Authorization", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
owner := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "delete_owner", "delete_owner@example.com")
|
||||
otherUser := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "delete_other", "delete_other@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, owner.User.ID, "Delete Post", "https://example.com/delete")
|
||||
|
||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/posts/%d", post.ID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+otherUser.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, otherUser.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusForbidden)
|
||||
|
||||
req = httptest.NewRequest("DELETE", fmt.Sprintf("/api/posts/%d", post.ID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+owner.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, owner.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("User_Profile_Access_Authorization", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user1 := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "profile_user1", "profile_user1@example.com")
|
||||
user2 := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "profile_user2", "profile_user2@example.com")
|
||||
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/users/%d", user1.User.ID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user2.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user2.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", user1.User.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if userData, ok := data["user"].(map[string]any); ok {
|
||||
if id, ok := userData["id"].(float64); ok && uint(id) != user1.User.ID {
|
||||
t.Errorf("Expected user ID %d, got %.0f", user1.User.ID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("User_Settings_Authorization", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "settings_auth_user", "settings_auth@example.com")
|
||||
otherUser := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "settings_auth_other", "settings_auth_other@example.com")
|
||||
|
||||
updateBody := map[string]string{
|
||||
"email": "newemail@example.com",
|
||||
}
|
||||
body, _ := json.Marshal(updateBody)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/auth/email", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+otherUser.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, otherUser.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if userData, ok := data["user"].(map[string]any); ok {
|
||||
if email, ok := userData["email"].(string); ok && email == "newemail@example.com" {
|
||||
if id, ok := userData["id"].(float64); ok && uint(id) != otherUser.User.ID {
|
||||
t.Error("Expected email update to affect the authenticated user, not another user")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateBody2 := map[string]string{
|
||||
"email": "anothernewemail@example.com",
|
||||
}
|
||||
body2, _ := json.Marshal(updateBody2)
|
||||
|
||||
req = httptest.NewRequest("PUT", "/api/auth/email", bytes.NewBuffer(body2))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Vote_Authorization", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "vote_auth_user", "vote_auth@example.com")
|
||||
postOwner := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "vote_auth_owner", "vote_auth_owner@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, postOwner.User.ID, "Vote Auth Post", "https://example.com/vote-auth")
|
||||
|
||||
voteBody := map[string]string{"type": "up"}
|
||||
body, _ := json.Marshal(voteBody)
|
||||
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
req = httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Protected_Endpoint_Without_Auth", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Protected_Endpoint_With_Invalid_Token", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("User_List_Authorization", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "list_auth_user", "list_auth@example.com")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/users", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
req = httptest.NewRequest("GET", "/api/users", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Refresh_Token_Authorization", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "refresh_auth_user", "refresh_auth@example.com")
|
||||
|
||||
loginResult, err := ctx.AuthService.Login("refresh_auth_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
refreshBody := map[string]string{
|
||||
"refresh_token": loginResult.RefreshToken,
|
||||
}
|
||||
body, _ := json.Marshal(refreshBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/refresh", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if _, exists := data["access_token"]; !exists {
|
||||
t.Error("Expected access_token in refresh response")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected data field in refresh response")
|
||||
}
|
||||
|
||||
refreshBody = map[string]string{
|
||||
"refresh_token": "invalid-refresh-token",
|
||||
}
|
||||
body, _ = json.Marshal(refreshBody)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/auth/refresh", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIntegration_CSRF_Protection(t *testing.T) {
|
||||
ctx := setupPageHandlerTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("CSRF_Blocks_Form_Without_Token", func(t *testing.T) {
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("username", "testuser")
|
||||
reqBody.Set("email", "test@example.com")
|
||||
reqBody.Set("password", "SecurePass123!")
|
||||
|
||||
req := httptest.NewRequest("POST", "/register", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status 403, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Invalid CSRF token") {
|
||||
t.Error("Expected CSRF error message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CSRF_Allows_Form_With_Valid_Token", func(t *testing.T) {
|
||||
getReq := httptest.NewRequest("GET", "/register", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
|
||||
cookies := getRec.Result().Cookies()
|
||||
var csrfCookie *http.Cookie
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "csrf_token" {
|
||||
csrfCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if csrfCookie == nil {
|
||||
t.Fatal("Expected CSRF cookie to be set")
|
||||
}
|
||||
|
||||
csrfToken := csrfCookie.Value
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("username", "csrf_user")
|
||||
reqBody.Set("email", "csrf@example.com")
|
||||
reqBody.Set("password", "SecurePass123!")
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/register", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(csrfCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Error("Expected form submission with valid CSRF token to succeed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CSRF_Allows_API_Requests", func(t *testing.T) {
|
||||
reqBody := map[string]string{
|
||||
"username": "api_user",
|
||||
"email": "api@example.com",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Error("Expected API requests to bypass CSRF protection")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CSRF_Blocks_Mismatched_Token", func(t *testing.T) {
|
||||
getReq := httptest.NewRequest("GET", "/register", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
|
||||
cookies := getRec.Result().Cookies()
|
||||
var csrfCookie *http.Cookie
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "csrf_token" {
|
||||
csrfCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if csrfCookie == nil {
|
||||
t.Fatal("Expected CSRF cookie to be set")
|
||||
}
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("username", "mismatch_user")
|
||||
reqBody.Set("email", "mismatch@example.com")
|
||||
reqBody.Set("password", "SecurePass123!")
|
||||
reqBody.Set("csrf_token", "wrong-token")
|
||||
|
||||
req := httptest.NewRequest("POST", "/register", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(csrfCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status 403, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Invalid CSRF token") {
|
||||
t.Error("Expected CSRF error message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CSRF_Allows_GET_Requests", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/register", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Error("Expected GET requests to bypass CSRF protection")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CSRF_Token_In_Header", func(t *testing.T) {
|
||||
getReq := httptest.NewRequest("GET", "/register", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
|
||||
cookies := getRec.Result().Cookies()
|
||||
var csrfCookie *http.Cookie
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "csrf_token" {
|
||||
csrfCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if csrfCookie == nil {
|
||||
t.Fatal("Expected CSRF cookie to be set")
|
||||
}
|
||||
|
||||
csrfToken := csrfCookie.Value
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("username", "header_user")
|
||||
reqBody.Set("email", "header@example.com")
|
||||
reqBody.Set("password", "SecurePass123!")
|
||||
|
||||
req := httptest.NewRequest("POST", "/register", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("X-CSRF-Token", csrfToken)
|
||||
req.AddCookie(csrfCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Error("Expected CSRF token in header to be accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CSRF_With_PageHandler_Forms", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "csrf_form_user", "csrf_form@example.com")
|
||||
|
||||
getReq := httptest.NewRequest("GET", "/posts/new", nil)
|
||||
getReq.AddCookie(&http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
|
||||
cookies := getRec.Result().Cookies()
|
||||
var csrfCookie *http.Cookie
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "csrf_token" {
|
||||
csrfCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if csrfCookie == nil {
|
||||
t.Fatal("Expected CSRF cookie to be set")
|
||||
}
|
||||
|
||||
csrfToken := csrfCookie.Value
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("title", "CSRF Test Post")
|
||||
reqBody.Set("url", "https://example.com/csrf-test")
|
||||
reqBody.Set("content", "Test content")
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/posts", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
req.AddCookie(csrfCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Error("Expected post creation with valid CSRF token to succeed")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_DataConsistency(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("Post_Creation_Consistency", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "consistency_user", "consistency@example.com")
|
||||
|
||||
postBody := map[string]string{
|
||||
"title": "Consistency Test Post",
|
||||
"url": "https://example.com/consistency",
|
||||
"content": "Test content",
|
||||
}
|
||||
body, _ := json.Marshal(postBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
createResponse := assertJSONResponse(t, rec, http.StatusCreated)
|
||||
if createResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
postData, ok := createResponse["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Response missing data")
|
||||
}
|
||||
|
||||
postID, ok := postData["id"].(float64)
|
||||
if !ok {
|
||||
t.Fatal("Response missing post id")
|
||||
}
|
||||
|
||||
createdTitle := postData["title"]
|
||||
createdURL := postData["url"]
|
||||
createdContent := postData["content"]
|
||||
|
||||
getReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%.0f", postID), nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(getRec, getReq)
|
||||
|
||||
getResponse := assertJSONResponse(t, getRec, http.StatusOK)
|
||||
if getResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
getPostData, ok := getResponse["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Get response missing data")
|
||||
}
|
||||
|
||||
if getPostData["title"] != createdTitle {
|
||||
t.Errorf("Title mismatch: created=%v, retrieved=%v", createdTitle, getPostData["title"])
|
||||
}
|
||||
|
||||
if getPostData["url"] != createdURL {
|
||||
t.Errorf("URL mismatch: created=%v, retrieved=%v", createdURL, getPostData["url"])
|
||||
}
|
||||
|
||||
if getPostData["content"] != createdContent {
|
||||
t.Errorf("Content mismatch: created=%v, retrieved=%v", createdContent, getPostData["content"])
|
||||
}
|
||||
|
||||
if getPostData["author_id"] == nil {
|
||||
t.Error("Expected author_id to be set")
|
||||
} else if authorID, ok := getPostData["author_id"].(float64); ok {
|
||||
if uint(authorID) != user.User.ID {
|
||||
t.Errorf("Author ID mismatch: expected=%d, got=%.0f", user.User.ID, authorID)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Author ID type mismatch: expected float64, got %T", getPostData["author_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Vote_Consistency", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "vote_consistency_user", "vote_consistency@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Vote Consistency Post", "https://example.com/vote-consistency")
|
||||
|
||||
voteBody := map[string]string{"type": "up"}
|
||||
body, _ := json.Marshal(voteBody)
|
||||
|
||||
voteReq := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(body))
|
||||
voteReq.Header.Set("Content-Type", "application/json")
|
||||
voteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
voteReq = testutils.WithUserContext(voteReq, middleware.UserIDKey, user.User.ID)
|
||||
voteReq = testutils.WithURLParams(voteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
voteRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(voteRec, voteReq)
|
||||
|
||||
assertStatus(t, voteRec, http.StatusOK)
|
||||
|
||||
getVotesReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d/votes", post.ID), nil)
|
||||
getVotesReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
getVotesReq = testutils.WithUserContext(getVotesReq, middleware.UserIDKey, user.User.ID)
|
||||
getVotesReq = testutils.WithURLParams(getVotesReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
getVotesRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(getVotesRec, getVotesReq)
|
||||
|
||||
votesResponse := assertJSONResponse(t, getVotesRec, http.StatusOK)
|
||||
if votesResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
votesData, ok := votesResponse["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Votes response missing data")
|
||||
}
|
||||
|
||||
votes, ok := votesData["votes"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("Votes response missing votes array")
|
||||
}
|
||||
|
||||
if len(votes) == 0 {
|
||||
t.Error("Expected at least one vote")
|
||||
}
|
||||
|
||||
foundUserVote := false
|
||||
for _, vote := range votes {
|
||||
if voteMap, ok := vote.(map[string]any); ok {
|
||||
var userIDVal any
|
||||
var exists bool
|
||||
if userIDVal, exists = voteMap["user_id"]; !exists {
|
||||
userIDVal, exists = voteMap["UserID"]
|
||||
}
|
||||
if exists && userIDVal != nil {
|
||||
if userID, ok := userIDVal.(float64); ok && uint(userID) == user.User.ID {
|
||||
var voteType string
|
||||
if vt, ok := voteMap["type"].(string); ok {
|
||||
voteType = vt
|
||||
} else if vt, ok := voteMap["Type"].(string); ok {
|
||||
voteType = vt
|
||||
}
|
||||
if voteType != "" && voteType != "up" {
|
||||
t.Errorf("Expected vote type 'up', got '%s'", voteType)
|
||||
}
|
||||
foundUserVote = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundUserVote {
|
||||
t.Error("User vote not found in votes list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Post_Update_Consistency", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "update_consistency_user", "update_consistency@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Original Title", "https://example.com/original")
|
||||
|
||||
updateBody := map[string]string{
|
||||
"title": "Updated Title",
|
||||
"content": "Updated content",
|
||||
}
|
||||
body, _ := json.Marshal(updateBody)
|
||||
|
||||
updateReq := httptest.NewRequest("PUT", fmt.Sprintf("/api/posts/%d", post.ID), bytes.NewBuffer(body))
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
updateReq = testutils.WithUserContext(updateReq, middleware.UserIDKey, user.User.ID)
|
||||
updateReq = testutils.WithURLParams(updateReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
updateRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(updateRec, updateReq)
|
||||
|
||||
assertStatus(t, updateRec, http.StatusOK)
|
||||
|
||||
getReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d", post.ID), nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(getRec, getReq)
|
||||
|
||||
getResponse := assertJSONResponse(t, getRec, http.StatusOK)
|
||||
if getResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
getPostData, ok := getResponse["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Get response missing data")
|
||||
}
|
||||
|
||||
if getPostData["title"] != "Updated Title" {
|
||||
t.Errorf("Title not updated: expected 'Updated Title', got %v", getPostData["title"])
|
||||
}
|
||||
|
||||
if getPostData["content"] != "Updated content" {
|
||||
t.Errorf("Content not updated: expected 'Updated content', got %v", getPostData["content"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("User_Posts_Consistency", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "user_posts_consistency", "user_posts_consistency@example.com")
|
||||
|
||||
post1 := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Post 1", "https://example.com/post1")
|
||||
post2 := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Post 2", "https://example.com/post2")
|
||||
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/users/%d/posts", user.User.ID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", user.User.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
|
||||
data, ok := response["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Response missing data")
|
||||
}
|
||||
|
||||
posts, ok := data["posts"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("Response missing posts array")
|
||||
}
|
||||
|
||||
if len(posts) < 2 {
|
||||
t.Errorf("Expected at least 2 posts, got %d", len(posts))
|
||||
}
|
||||
|
||||
foundPost1 := false
|
||||
foundPost2 := false
|
||||
for _, post := range posts {
|
||||
if postMap, ok := post.(map[string]any); ok {
|
||||
if postID, ok := postMap["id"].(float64); ok {
|
||||
if uint(postID) == post1.ID {
|
||||
foundPost1 = true
|
||||
}
|
||||
if uint(postID) == post2.ID {
|
||||
foundPost2 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundPost1 {
|
||||
t.Error("Post 1 not found in user posts")
|
||||
}
|
||||
|
||||
if !foundPost2 {
|
||||
t.Error("Post 2 not found in user posts")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Post_Deletion_Consistency", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "delete_consistency_user", "delete_consistency@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Delete Consistency Post", "https://example.com/delete-consistency")
|
||||
|
||||
deleteReq := httptest.NewRequest("DELETE", fmt.Sprintf("/api/posts/%d", post.ID), nil)
|
||||
deleteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
deleteReq = testutils.WithUserContext(deleteReq, middleware.UserIDKey, user.User.ID)
|
||||
deleteReq = testutils.WithURLParams(deleteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
deleteRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(deleteRec, deleteReq)
|
||||
|
||||
assertStatus(t, deleteRec, http.StatusOK)
|
||||
|
||||
getReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d", post.ID), nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(getRec, getReq)
|
||||
|
||||
assertStatus(t, getRec, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("Vote_Removal_Consistency", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "vote_remove_consistency", "vote_remove_consistency@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Vote Remove Consistency", "https://example.com/vote-remove-consistency")
|
||||
|
||||
voteBody := map[string]string{"type": "up"}
|
||||
body, _ := json.Marshal(voteBody)
|
||||
|
||||
voteReq := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(body))
|
||||
voteReq.Header.Set("Content-Type", "application/json")
|
||||
voteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
voteReq = testutils.WithUserContext(voteReq, middleware.UserIDKey, user.User.ID)
|
||||
voteReq = testutils.WithURLParams(voteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
voteRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(voteRec, voteReq)
|
||||
|
||||
assertStatus(t, voteRec, http.StatusOK)
|
||||
|
||||
removeVoteReq := httptest.NewRequest("DELETE", fmt.Sprintf("/api/posts/%d/vote", post.ID), nil)
|
||||
removeVoteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
removeVoteReq = testutils.WithUserContext(removeVoteReq, middleware.UserIDKey, user.User.ID)
|
||||
removeVoteReq = testutils.WithURLParams(removeVoteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
removeVoteRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(removeVoteRec, removeVoteReq)
|
||||
|
||||
assertStatus(t, removeVoteRec, http.StatusOK)
|
||||
|
||||
getVotesReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d/votes", post.ID), nil)
|
||||
getVotesReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
getVotesReq = testutils.WithUserContext(getVotesReq, middleware.UserIDKey, user.User.ID)
|
||||
getVotesReq = testutils.WithURLParams(getVotesReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
getVotesRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(getVotesRec, getVotesReq)
|
||||
|
||||
votesResponse := assertJSONResponse(t, getVotesRec, http.StatusOK)
|
||||
if votesResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if data, ok := votesResponse["data"].(map[string]any); ok {
|
||||
if votes, ok := data["votes"].([]any); ok {
|
||||
for _, vote := range votes {
|
||||
if voteMap, ok := vote.(map[string]any); ok {
|
||||
if userID, ok := voteMap["user_id"].(float64); ok && uint(userID) == user.User.ID {
|
||||
t.Error("User vote still exists after removal")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_EdgeCases(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("Expired_Token_Handling", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "expired_user", "expired@example.com")
|
||||
|
||||
expiredToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDAwMDAwMDB9.expired"
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+expiredToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Concurrent_Vote_Operations", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user1 := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "vote_user1", "vote1@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user1.User.ID, "Concurrent Vote Post", "https://example.com/concurrent")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 10)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voteBody := map[string]string{"type": "up"}
|
||||
body, _ := json.Marshal(voteBody)
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user1.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user1.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
errors <- fmt.Errorf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
for err := range errors {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Large_Payload_Handling", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "large_user", "large@example.com")
|
||||
|
||||
largeContent := make([]byte, 10001)
|
||||
for i := range largeContent {
|
||||
largeContent[i] = 'a'
|
||||
}
|
||||
|
||||
postBody := map[string]string{
|
||||
"title": "Large Post",
|
||||
"url": "https://example.com/large",
|
||||
"content": string(largeContent),
|
||||
}
|
||||
body, _ := json.Marshal(postBody)
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusBadRequest)
|
||||
|
||||
smallContent := make([]byte, 1000)
|
||||
for i := range smallContent {
|
||||
smallContent[i] = 'a'
|
||||
}
|
||||
|
||||
postBody2 := map[string]string{
|
||||
"title": "Small Post",
|
||||
"url": "https://example.com/small",
|
||||
"content": string(smallContent),
|
||||
}
|
||||
body2, _ := json.Marshal(postBody2)
|
||||
req2 := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(body2))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req2 = testutils.WithUserContext(req2, middleware.UserIDKey, user.User.ID)
|
||||
rec2 := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec2, req2)
|
||||
|
||||
assertStatus(t, rec2, http.StatusCreated)
|
||||
})
|
||||
|
||||
t.Run("Malformed_JSON_Payloads", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "malformed_user", "malformed@example.com")
|
||||
|
||||
malformedPayloads := []string{
|
||||
`{"title": "test"`,
|
||||
`{"title": "test",}`,
|
||||
`{title: "test"}`,
|
||||
`{"title": 'test'}`,
|
||||
`{"title": "test" "url": ""}`,
|
||||
}
|
||||
|
||||
for _, payload := range malformedPayloads {
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBufferString(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Race_Condition_Vote_Removal", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "race_user", "race@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Race Post", "https://example.com/race")
|
||||
|
||||
voteBody := map[string]string{"type": "up"}
|
||||
body, _ := json.Marshal(voteBody)
|
||||
|
||||
voteReq := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(body))
|
||||
voteReq.Header.Set("Content-Type", "application/json")
|
||||
voteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
voteReq = testutils.WithUserContext(voteReq, middleware.UserIDKey, user.User.ID)
|
||||
voteReq = testutils.WithURLParams(voteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
voteRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(voteRec, voteReq)
|
||||
assertStatus(t, voteRec, http.StatusOK)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 3; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/posts/%d/vote", post.ID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
getVotesReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d/votes", post.ID), nil)
|
||||
getVotesReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
getVotesReq = testutils.WithUserContext(getVotesReq, middleware.UserIDKey, user.User.ID)
|
||||
getVotesReq = testutils.WithURLParams(getVotesReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
getVotesRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(getVotesRec, getVotesReq)
|
||||
|
||||
votesResponse := assertJSONResponse(t, getVotesRec, http.StatusOK)
|
||||
if votesResponse != nil {
|
||||
if data, ok := votesResponse["data"].(map[string]any); ok {
|
||||
if votes, ok := data["votes"].([]any); ok {
|
||||
userVoteCount := 0
|
||||
for _, vote := range votes {
|
||||
if voteMap, ok := vote.(map[string]any); ok {
|
||||
if userID, ok := voteMap["user_id"].(float64); ok && uint(userID) == user.User.ID {
|
||||
userVoteCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
if userVoteCount > 1 {
|
||||
t.Errorf("Expected at most 1 vote from user, got %d", userVoteCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_EmailService(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("Registration_Email_Sent", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
reqBody := map[string]string{
|
||||
"username": "email_reg_user",
|
||||
"email": "email_reg@example.com",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusCreated)
|
||||
|
||||
token := ctx.Suite.EmailSender.VerificationToken()
|
||||
if token == "" {
|
||||
t.Error("Expected verification email to be sent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PasswordReset_Email_Sent", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "email_reset_user",
|
||||
Email: "email_reset@example.com",
|
||||
Password: testutils.HashPassword("OldPassword123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := ctx.Suite.UserRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
reqBody := map[string]string{
|
||||
"username_or_email": "email_reset_user",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/forgot-password", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
token := ctx.Suite.EmailSender.PasswordResetToken()
|
||||
if token == "" {
|
||||
t.Error("Expected password reset email to be sent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AccountDeletion_Email_Sent", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "email_del_user", "email_del@example.com")
|
||||
|
||||
reqBody := map[string]string{}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("DELETE", "/api/auth/account", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
token := ctx.Suite.EmailSender.DeletionToken()
|
||||
if token == "" {
|
||||
t.Error("Expected account deletion email to be sent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmailChange_Verification_Sent", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "email_change_user", "email_change@example.com")
|
||||
|
||||
reqBody := map[string]string{
|
||||
"email": "newemail@example.com",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("PUT", "/api/auth/email", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
token := ctx.Suite.EmailSender.VerificationToken()
|
||||
if token == "" {
|
||||
t.Error("Expected email change verification to be sent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Email_Template_Content", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
reqBody := map[string]string{
|
||||
"username": "template_user",
|
||||
"email": "template@example.com",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
token := ctx.Suite.EmailSender.VerificationToken()
|
||||
if token == "" {
|
||||
t.Fatal("Expected verification token")
|
||||
}
|
||||
|
||||
if len(token) < 10 {
|
||||
t.Error("Expected token to have reasonable format")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_EndToEndUserJourneys(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("Complete_Registration_To_Post_Creation_Journey", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
registerBody := map[string]string{
|
||||
"username": "journey_user",
|
||||
"email": "journey@example.com",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
body, _ := json.Marshal(registerBody)
|
||||
registerReq := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
|
||||
registerReq.Header.Set("Content-Type", "application/json")
|
||||
registerRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(registerRec, registerReq)
|
||||
|
||||
assertStatus(t, registerRec, http.StatusCreated)
|
||||
|
||||
verificationToken := ctx.Suite.EmailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
t.Fatal("Verification token not sent")
|
||||
}
|
||||
|
||||
confirmReq := httptest.NewRequest("GET", "/api/auth/confirm?token="+url.QueryEscape(verificationToken), nil)
|
||||
confirmRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(confirmRec, confirmReq)
|
||||
|
||||
assertStatus(t, confirmRec, http.StatusOK)
|
||||
|
||||
loginBody := map[string]string{
|
||||
"username": "journey_user",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
loginBodyBytes, _ := json.Marshal(loginBody)
|
||||
loginReq := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(loginBodyBytes))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(loginRec, loginReq)
|
||||
|
||||
loginResponse := assertJSONResponse(t, loginRec, http.StatusOK)
|
||||
if loginResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
data, ok := loginResponse["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Login response missing data")
|
||||
}
|
||||
|
||||
var token string
|
||||
if accessToken, ok := data["access_token"].(string); ok && accessToken != "" {
|
||||
token = accessToken
|
||||
} else if tokenVal, ok := data["token"].(string); ok && tokenVal != "" {
|
||||
token = tokenVal
|
||||
} else {
|
||||
t.Fatal("Login response missing access_token or token")
|
||||
}
|
||||
|
||||
var userID uint
|
||||
if userData, ok := data["user"].(map[string]any); ok {
|
||||
if id, ok := userData["id"].(float64); ok {
|
||||
userID = uint(id)
|
||||
} else if id, ok := userData["ID"].(float64); ok {
|
||||
userID = uint(id)
|
||||
}
|
||||
}
|
||||
if userID == 0 {
|
||||
if id, ok := data["user_id"].(float64); ok {
|
||||
userID = uint(id)
|
||||
}
|
||||
}
|
||||
if userID == 0 {
|
||||
t.Fatalf("Login response missing user.id. Data: %+v", data)
|
||||
}
|
||||
|
||||
postBody := map[string]string{
|
||||
"title": "Journey Test Post",
|
||||
"url": "https://example.com/journey",
|
||||
"content": "Test content",
|
||||
}
|
||||
postBodyBytes, _ := json.Marshal(postBody)
|
||||
postReq := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(postBodyBytes))
|
||||
postReq.Header.Set("Content-Type", "application/json")
|
||||
postReq.Header.Set("Authorization", "Bearer "+token)
|
||||
postReq = testutils.WithUserContext(postReq, middleware.UserIDKey, uint(userID))
|
||||
postRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(postRec, postReq)
|
||||
|
||||
postResponse := assertJSONResponse(t, postRec, http.StatusCreated)
|
||||
if postResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
postData, ok := postResponse["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Post response missing data")
|
||||
}
|
||||
|
||||
postID, ok := postData["id"].(float64)
|
||||
if !ok {
|
||||
t.Fatal("Post response missing id")
|
||||
}
|
||||
|
||||
getPostReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%.0f", postID), nil)
|
||||
getPostRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(getPostRec, getPostReq)
|
||||
|
||||
assertStatus(t, getPostRec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Complete_Password_Reset_Journey", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "reset_journey_user", "reset_journey@example.com")
|
||||
|
||||
resetBody := map[string]string{
|
||||
"username_or_email": "reset_journey@example.com",
|
||||
}
|
||||
resetBodyBytes, _ := json.Marshal(resetBody)
|
||||
resetReq := httptest.NewRequest("POST", "/api/auth/forgot-password", bytes.NewBuffer(resetBodyBytes))
|
||||
resetReq.Header.Set("Content-Type", "application/json")
|
||||
resetRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(resetRec, resetReq)
|
||||
|
||||
assertStatus(t, resetRec, http.StatusOK)
|
||||
|
||||
resetToken := ctx.Suite.EmailSender.GetLastPasswordResetToken()
|
||||
if resetToken == "" {
|
||||
t.Fatal("Password reset token not sent")
|
||||
}
|
||||
|
||||
newPasswordBody := map[string]string{
|
||||
"token": resetToken,
|
||||
"new_password": "NewSecurePass123!",
|
||||
}
|
||||
newPasswordBodyBytes, _ := json.Marshal(newPasswordBody)
|
||||
newPasswordReq := httptest.NewRequest("POST", "/api/auth/reset-password", bytes.NewBuffer(newPasswordBodyBytes))
|
||||
newPasswordReq.Header.Set("Content-Type", "application/json")
|
||||
newPasswordRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(newPasswordRec, newPasswordReq)
|
||||
|
||||
assertStatus(t, newPasswordRec, http.StatusOK)
|
||||
|
||||
loginBody := map[string]string{
|
||||
"username": "reset_journey_user",
|
||||
"password": "NewSecurePass123!",
|
||||
}
|
||||
loginBodyBytes, _ := json.Marshal(loginBody)
|
||||
loginReq := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(loginBodyBytes))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(loginRec, loginReq)
|
||||
|
||||
assertStatus(t, loginRec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Complete_Vote_And_Unvote_Journey", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "vote_journey_user", "vote_journey@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Vote Journey Post", "https://example.com/vote-journey")
|
||||
|
||||
voteBody := map[string]string{"type": "up"}
|
||||
voteBodyBytes, _ := json.Marshal(voteBody)
|
||||
voteReq := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(voteBodyBytes))
|
||||
voteReq.Header.Set("Content-Type", "application/json")
|
||||
voteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
voteReq = testutils.WithUserContext(voteReq, middleware.UserIDKey, user.User.ID)
|
||||
voteReq = testutils.WithURLParams(voteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
voteRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(voteRec, voteReq)
|
||||
|
||||
assertStatus(t, voteRec, http.StatusOK)
|
||||
|
||||
getVotesReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d/votes", post.ID), nil)
|
||||
getVotesReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
getVotesReq = testutils.WithUserContext(getVotesReq, middleware.UserIDKey, user.User.ID)
|
||||
getVotesReq = testutils.WithURLParams(getVotesReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
getVotesRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(getVotesRec, getVotesReq)
|
||||
|
||||
votesResponse := assertJSONResponse(t, getVotesRec, http.StatusOK)
|
||||
if votesResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if data, ok := votesResponse["data"].(map[string]any); ok {
|
||||
if votes, ok := data["votes"].([]any); ok && len(votes) > 0 {
|
||||
unvoteReq := httptest.NewRequest("DELETE", fmt.Sprintf("/api/posts/%d/vote", post.ID), nil)
|
||||
unvoteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
unvoteReq = testutils.WithUserContext(unvoteReq, middleware.UserIDKey, user.User.ID)
|
||||
unvoteReq = testutils.WithURLParams(unvoteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
unvoteRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(unvoteRec, unvoteReq)
|
||||
|
||||
assertStatus(t, unvoteRec, http.StatusOK)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Complete_Page_Handler_Registration_Journey", func(t *testing.T) {
|
||||
pageCtx := setupPageHandlerTestContext(t)
|
||||
pageRouter := pageCtx.Router
|
||||
pageCtx.Suite.EmailSender.Reset()
|
||||
|
||||
csrfToken := getCSRFToken(t, pageRouter, "/register")
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("username", "page_journey_user")
|
||||
reqBody.Set("email", "page_journey@example.com")
|
||||
reqBody.Set("password", "SecurePass123!")
|
||||
reqBody.Set("password_confirm", "SecurePass123!")
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/register", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
|
||||
rec := httptest.NewRecorder()
|
||||
pageRouter.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusSeeOther)
|
||||
|
||||
verificationToken := pageCtx.Suite.EmailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
t.Fatal("Verification token not sent")
|
||||
}
|
||||
|
||||
confirmReq := httptest.NewRequest("GET", "/confirm?token="+url.QueryEscape(verificationToken), nil)
|
||||
confirmRec := httptest.NewRecorder()
|
||||
pageRouter.ServeHTTP(confirmRec, confirmReq)
|
||||
|
||||
assertStatusRange(t, confirmRec, http.StatusOK, http.StatusSeeOther)
|
||||
|
||||
loginCSRFToken := getCSRFToken(t, pageRouter, "/login")
|
||||
|
||||
loginBody := url.Values{}
|
||||
loginBody.Set("username", "page_journey_user")
|
||||
loginBody.Set("password", "SecurePass123!")
|
||||
loginBody.Set("csrf_token", loginCSRFToken)
|
||||
|
||||
loginReq := httptest.NewRequest("POST", "/login", strings.NewReader(loginBody.Encode()))
|
||||
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
loginReq.AddCookie(&http.Cookie{Name: "csrf_token", Value: loginCSRFToken})
|
||||
loginRec := httptest.NewRecorder()
|
||||
pageRouter.ServeHTTP(loginRec, loginReq)
|
||||
|
||||
assertStatus(t, loginRec, http.StatusSeeOther)
|
||||
|
||||
loginCookies := loginRec.Result().Cookies()
|
||||
var authToken string
|
||||
for _, cookie := range loginCookies {
|
||||
if cookie.Name == "auth_token" {
|
||||
authToken = cookie.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if authToken == "" {
|
||||
t.Fatal("Auth token not set after login")
|
||||
}
|
||||
|
||||
homeReq := httptest.NewRequest("GET", "/", nil)
|
||||
homeReq.AddCookie(&http.Cookie{Name: "auth_token", Value: authToken})
|
||||
homeRec := httptest.NewRecorder()
|
||||
pageRouter.ServeHTTP(homeRec, homeReq)
|
||||
|
||||
assertStatus(t, homeRec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Complete_Post_Creation_And_Update_Journey", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "post_update_journey_user", "post_update_journey@example.com")
|
||||
|
||||
postBody := map[string]string{
|
||||
"title": "Original Title",
|
||||
"url": "https://example.com/original",
|
||||
"content": "Original content",
|
||||
}
|
||||
postBodyBytes, _ := json.Marshal(postBody)
|
||||
postReq := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(postBodyBytes))
|
||||
postReq.Header.Set("Content-Type", "application/json")
|
||||
postReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
postReq = testutils.WithUserContext(postReq, middleware.UserIDKey, user.User.ID)
|
||||
postRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(postRec, postReq)
|
||||
|
||||
postResponse := assertJSONResponse(t, postRec, http.StatusCreated)
|
||||
if postResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
postData, ok := postResponse["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Post response missing data")
|
||||
}
|
||||
|
||||
postID, ok := postData["id"].(float64)
|
||||
if !ok {
|
||||
t.Fatal("Post response missing id")
|
||||
}
|
||||
|
||||
updateBody := map[string]string{
|
||||
"title": "Updated Title",
|
||||
"content": "Updated content",
|
||||
}
|
||||
updateBodyBytes, _ := json.Marshal(updateBody)
|
||||
updateReq := httptest.NewRequest("PUT", fmt.Sprintf("/api/posts/%.0f", postID), bytes.NewBuffer(updateBodyBytes))
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
updateReq = testutils.WithUserContext(updateReq, middleware.UserIDKey, user.User.ID)
|
||||
updateReq = testutils.WithURLParams(updateReq, map[string]string{"id": fmt.Sprintf("%.0f", postID)})
|
||||
updateRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(updateRec, updateReq)
|
||||
|
||||
updateResponse := assertJSONResponse(t, updateRec, http.StatusOK)
|
||||
if updateResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
getPostReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%.0f", postID), nil)
|
||||
getPostRec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(getPostRec, getPostReq)
|
||||
|
||||
getPostResponse := assertJSONResponse(t, getPostRec, http.StatusOK)
|
||||
if getPostResponse == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if data, ok := getPostResponse["data"].(map[string]any); ok {
|
||||
if post, ok := data["post"].(map[string]any); ok {
|
||||
if title, ok := post["title"].(string); ok && title != "Updated Title" {
|
||||
t.Errorf("Post title not updated: expected 'Updated Title', got '%s'", title)
|
||||
}
|
||||
if content, ok := post["content"].(string); ok && content != "Updated content" {
|
||||
t.Errorf("Post content not updated: expected 'Updated content', got '%s'", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_ErrorPropagation(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("Invalid_JSON_Error_Propagation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "json_error_user", "json_error@example.com")
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer([]byte("invalid json{")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("Validation_Error_Propagation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
reqBody := map[string]string{
|
||||
"username": "",
|
||||
"email": "invalid-email",
|
||||
"password": "weak",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("Database_Error_Propagation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "db_error_user", "db_error@example.com")
|
||||
|
||||
reqBody := map[string]string{
|
||||
"title": "Test Post",
|
||||
"url": "https://example.com/test",
|
||||
"content": "Test content",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code == http.StatusInternalServerError {
|
||||
assertErrorResponse(t, rec, http.StatusInternalServerError)
|
||||
} else {
|
||||
assertStatus(t, rec, http.StatusCreated)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NotFound_Error_Propagation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "notfound_error_user", "notfound_error@example.com")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/posts/999999", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": "999999"})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("Unauthorized_Error_Propagation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
reqBody := map[string]string{
|
||||
"title": "Test Post",
|
||||
"url": "https://example.com/test",
|
||||
"content": "Test content",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Forbidden_Error_Propagation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
owner := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "forbidden_owner", "forbidden_owner@example.com")
|
||||
otherUser := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "forbidden_other", "forbidden_other@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, owner.User.ID, "Forbidden Post", "https://example.com/forbidden")
|
||||
|
||||
updateBody := map[string]string{
|
||||
"title": "Updated Title",
|
||||
"content": "Updated content",
|
||||
}
|
||||
body, _ := json.Marshal(updateBody)
|
||||
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/api/posts/%d", post.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+otherUser.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, otherUser.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusForbidden)
|
||||
})
|
||||
|
||||
t.Run("Service_Error_Propagation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
reqBody := map[string]string{
|
||||
"username": "existing_user",
|
||||
"email": "existing@example.com",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusCreated)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusBadRequest, http.StatusConflict)
|
||||
assertErrorResponse(t, rec, rec.Code)
|
||||
})
|
||||
|
||||
t.Run("Middleware_Error_Propagation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer expired.invalid.token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Handler_Error_Response_Format", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/nonexistent", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
ctx.Router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code == http.StatusNotFound {
|
||||
if rec.Header().Get("Content-Type") == "application/json" {
|
||||
assertErrorResponse(t, rec, http.StatusNotFound)
|
||||
} else {
|
||||
if rec.Body.Len() == 0 {
|
||||
t.Error("Expected error response body")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/handlers"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_Handlers(t *testing.T) {
|
||||
suite := testutils.NewServiceSuite(t)
|
||||
|
||||
authService, err := services.NewAuthFacadeForTest(testutils.AppTestConfig, suite.UserRepo, suite.PostRepo, suite.DeletionRepo, suite.RefreshTokenRepo, suite.EmailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
|
||||
voteService := services.NewVoteService(suite.VoteRepo, suite.PostRepo, suite.DB)
|
||||
emailSender := suite.EmailSender
|
||||
userRepo := suite.UserRepo
|
||||
postRepo := suite.PostRepo
|
||||
titleFetcher := suite.TitleFetcher
|
||||
|
||||
authHandler := handlers.NewAuthHandler(authService, userRepo)
|
||||
postHandler := handlers.NewPostHandler(postRepo, titleFetcher, voteService)
|
||||
voteHandler := handlers.NewVoteHandler(voteService)
|
||||
userHandler := handlers.NewUserHandler(userRepo, authService)
|
||||
|
||||
t.Run("Auth_Handler_Complete_Workflow", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
registerData := map[string]string{
|
||||
"username": "handler_user",
|
||||
"email": "handler@example.com",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
registerBody, _ := json.Marshal(registerData)
|
||||
registerReq := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(registerBody))
|
||||
registerReq.Header.Set("Content-Type", "application/json")
|
||||
registerResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Register(registerResp, registerReq)
|
||||
if registerResp.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %d", registerResp.Code)
|
||||
}
|
||||
|
||||
var registerPayload map[string]any
|
||||
if err := json.Unmarshal(registerResp.Body.Bytes(), ®isterPayload); err != nil {
|
||||
t.Fatalf("Failed to decode register response: %v", err)
|
||||
}
|
||||
if success, _ := registerPayload["success"].(bool); !success {
|
||||
t.Fatalf("Expected register response success, got %v", registerPayload)
|
||||
}
|
||||
|
||||
user, err := userRepo.GetByUsername("handler_user")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user after registration: %v", err)
|
||||
}
|
||||
|
||||
mockToken := "test-verification-token"
|
||||
|
||||
hashedToken := testutils.HashVerificationToken(mockToken)
|
||||
|
||||
user.EmailVerificationToken = hashedToken
|
||||
if err := userRepo.Update(user); err != nil {
|
||||
t.Fatalf("Failed to update user with mock token: %v", err)
|
||||
}
|
||||
|
||||
confirmReq := httptest.NewRequest(http.MethodGet, "/api/auth/confirm?token="+url.QueryEscape(mockToken), nil)
|
||||
confirmResp := httptest.NewRecorder()
|
||||
authHandler.ConfirmEmail(confirmResp, confirmReq)
|
||||
if confirmResp.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200 when confirming email via handler, got %d", confirmResp.Code)
|
||||
}
|
||||
|
||||
loginSeed := createAuthenticatedUser(t, authService, userRepo, "auth_handler_login", "auth_handler_login@example.com")
|
||||
|
||||
loginAuth, err := authService.Login(loginSeed.User.Username, "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Service login failed for seeded user: %v", err)
|
||||
}
|
||||
|
||||
meReq := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
meReq.Header.Set("Authorization", "Bearer "+loginAuth.AccessToken)
|
||||
meReq = testutils.WithUserContext(meReq, middleware.UserIDKey, loginSeed.User.ID)
|
||||
meResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Me(meResp, meReq)
|
||||
if meResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", meResp.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Auth_Handler_Security_Validation", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
weakData := map[string]string{
|
||||
"username": "weak_user",
|
||||
"email": "weak@example.com",
|
||||
"password": "123",
|
||||
}
|
||||
weakBody, _ := json.Marshal(weakData)
|
||||
weakReq := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(weakBody))
|
||||
weakReq.Header.Set("Content-Type", "application/json")
|
||||
weakResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Register(weakResp, weakReq)
|
||||
if weakResp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for weak password, got %d", weakResp.Code)
|
||||
}
|
||||
|
||||
var weakErrorResp map[string]any
|
||||
if err := json.Unmarshal(weakResp.Body.Bytes(), &weakErrorResp); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
if success, _ := weakErrorResp["success"].(bool); success {
|
||||
t.Error("Expected error response to have success=false")
|
||||
}
|
||||
if errorMsg, ok := weakErrorResp["error"].(string); !ok || errorMsg == "" {
|
||||
t.Error("Expected error response to contain validation error message")
|
||||
}
|
||||
|
||||
invalidData := map[string]string{
|
||||
"username": "invalid_user",
|
||||
"email": "not-an-email",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
invalidBody, _ := json.Marshal(invalidData)
|
||||
invalidReq := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(invalidBody))
|
||||
invalidReq.Header.Set("Content-Type", "application/json")
|
||||
invalidResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Register(invalidResp, invalidReq)
|
||||
if invalidResp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for invalid email, got %d", invalidResp.Code)
|
||||
}
|
||||
|
||||
var invalidEmailErrorResp map[string]any
|
||||
if err := json.Unmarshal(invalidResp.Body.Bytes(), &invalidEmailErrorResp); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
if success, _ := invalidEmailErrorResp["success"].(bool); success {
|
||||
t.Error("Expected error response to have success=false")
|
||||
}
|
||||
if errorMsg, ok := invalidEmailErrorResp["error"].(string); !ok || errorMsg == "" {
|
||||
t.Error("Expected error response to contain validation error message")
|
||||
}
|
||||
|
||||
incompleteData := map[string]string{
|
||||
"username": "incomplete_user",
|
||||
}
|
||||
incompleteBody, _ := json.Marshal(incompleteData)
|
||||
incompleteReq := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(incompleteBody))
|
||||
incompleteReq.Header.Set("Content-Type", "application/json")
|
||||
incompleteResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Register(incompleteResp, incompleteReq)
|
||||
if incompleteResp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for missing fields, got %d", incompleteResp.Code)
|
||||
}
|
||||
|
||||
var incompleteErrorResp map[string]any
|
||||
if err := json.Unmarshal(incompleteResp.Body.Bytes(), &incompleteErrorResp); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
if success, _ := incompleteErrorResp["success"].(bool); success {
|
||||
t.Error("Expected error response to have success=false")
|
||||
}
|
||||
if errorMsg, ok := incompleteErrorResp["error"].(string); !ok || errorMsg == "" {
|
||||
t.Error("Expected error response to contain validation error message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Post_Handler_Complete_Workflow", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createAuthenticatedUser(t, authService, userRepo, "post_user", "post@example.com")
|
||||
|
||||
postData := map[string]string{
|
||||
"title": "Handler Test Post",
|
||||
"url": "https://example.com/handler-test",
|
||||
"content": "This is a handler test post",
|
||||
}
|
||||
postBody, _ := json.Marshal(postData)
|
||||
postReq := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(postBody))
|
||||
postReq.Header.Set("Content-Type", "application/json")
|
||||
postReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
postReq = testutils.WithUserContext(postReq, middleware.UserIDKey, user.User.ID)
|
||||
postResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.CreatePost(postResp, postReq)
|
||||
if postResp.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %d", postResp.Code)
|
||||
}
|
||||
|
||||
var postResult map[string]any
|
||||
if err := json.Unmarshal(postResp.Body.Bytes(), &postResult); err != nil {
|
||||
t.Fatalf("Failed to decode post response: %v", err)
|
||||
}
|
||||
postDetails, ok := postResult["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Expected data object in post response, got %v", postResult)
|
||||
}
|
||||
postID, ok := postDetails["id"].(float64)
|
||||
if !ok {
|
||||
t.Fatal("Expected post ID in response")
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d", int(postID)), nil)
|
||||
getReq = testutils.WithURLParams(getReq, map[string]string{"id": fmt.Sprintf("%d", int(postID))})
|
||||
getResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.GetPost(getResp, getReq)
|
||||
if getResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", getResp.Code)
|
||||
}
|
||||
|
||||
postsReq := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
postsResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.GetPosts(postsResp, postsReq)
|
||||
if postsResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", postsResp.Code)
|
||||
}
|
||||
|
||||
searchReq := httptest.NewRequest("GET", "/api/posts/search?q=handler", nil)
|
||||
searchResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.SearchPosts(searchResp, searchReq)
|
||||
if searchResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", searchResp.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Post_Handler_Security_Validation", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
postData := map[string]string{
|
||||
"title": "Unauthorized Post",
|
||||
"url": "https://example.com/unauthorized",
|
||||
"content": "This should fail",
|
||||
}
|
||||
postBody, _ := json.Marshal(postData)
|
||||
postReq := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(postBody))
|
||||
postReq.Header.Set("Content-Type", "application/json")
|
||||
postResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.CreatePost(postResp, postReq)
|
||||
if postResp.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401 for unauthenticated post creation, got %d", postResp.Code)
|
||||
}
|
||||
|
||||
var authErrorResp map[string]any
|
||||
if err := json.Unmarshal(postResp.Body.Bytes(), &authErrorResp); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
if success, _ := authErrorResp["success"].(bool); success {
|
||||
t.Error("Expected error response to have success=false")
|
||||
}
|
||||
if errorMsg, ok := authErrorResp["error"].(string); !ok || errorMsg == "" {
|
||||
t.Error("Expected error response to contain authentication error message")
|
||||
}
|
||||
|
||||
user := createAuthenticatedUser(t, authService, userRepo, "security_user", "security@example.com")
|
||||
|
||||
invalidData := map[string]string{
|
||||
"title": "",
|
||||
"url": "not-a-url",
|
||||
"content": "Invalid post",
|
||||
}
|
||||
invalidBody, _ := json.Marshal(invalidData)
|
||||
invalidReq := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(invalidBody))
|
||||
invalidReq.Header.Set("Content-Type", "application/json")
|
||||
invalidReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
invalidReq = testutils.WithUserContext(invalidReq, middleware.UserIDKey, user.User.ID)
|
||||
invalidResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.CreatePost(invalidResp, invalidReq)
|
||||
if invalidResp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for invalid post data, got %d", invalidResp.Code)
|
||||
}
|
||||
|
||||
var postValidationErrorResp map[string]any
|
||||
if err := json.Unmarshal(invalidResp.Body.Bytes(), &postValidationErrorResp); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
if success, _ := postValidationErrorResp["success"].(bool); success {
|
||||
t.Error("Expected error response to have success=false")
|
||||
}
|
||||
if errorMsg, ok := postValidationErrorResp["error"].(string); !ok || errorMsg == "" {
|
||||
t.Error("Expected error response to contain validation error message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Vote_Handler_Complete_Workflow", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createAuthenticatedUser(t, authService, userRepo, "vote_handler_user", "vote_handler@example.com")
|
||||
post := testutils.CreatePostWithRepo(t, postRepo, user.User.ID, "Vote Handler Test Post", "https://example.com/vote-handler")
|
||||
|
||||
voteData := map[string]string{
|
||||
"type": "up",
|
||||
}
|
||||
voteBody, _ := json.Marshal(voteData)
|
||||
voteReq := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(voteBody))
|
||||
voteReq.Header.Set("Content-Type", "application/json")
|
||||
voteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
voteReq = testutils.WithUserContext(voteReq, middleware.UserIDKey, user.User.ID)
|
||||
voteReq = testutils.WithURLParams(voteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
voteResp := httptest.NewRecorder()
|
||||
|
||||
voteHandler.CastVote(voteResp, voteReq)
|
||||
if voteResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", voteResp.Code)
|
||||
}
|
||||
|
||||
getVoteReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d/vote", post.ID), nil)
|
||||
getVoteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
getVoteReq = testutils.WithUserContext(getVoteReq, middleware.UserIDKey, user.User.ID)
|
||||
getVoteReq = testutils.WithURLParams(getVoteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
getVoteResp := httptest.NewRecorder()
|
||||
|
||||
voteHandler.GetUserVote(getVoteResp, getVoteReq)
|
||||
if getVoteResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", getVoteResp.Code)
|
||||
}
|
||||
|
||||
getPostVotesReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d/votes", post.ID), nil)
|
||||
getPostVotesReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
getPostVotesReq = testutils.WithUserContext(getPostVotesReq, middleware.UserIDKey, user.User.ID)
|
||||
getPostVotesReq = testutils.WithURLParams(getPostVotesReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
getPostVotesResp := httptest.NewRecorder()
|
||||
|
||||
voteHandler.GetPostVotes(getPostVotesResp, getPostVotesReq)
|
||||
if getPostVotesResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", getPostVotesResp.Code)
|
||||
}
|
||||
|
||||
removeVoteReq := httptest.NewRequest("DELETE", fmt.Sprintf("/api/posts/%d/vote", post.ID), nil)
|
||||
removeVoteReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
removeVoteReq = testutils.WithUserContext(removeVoteReq, middleware.UserIDKey, user.User.ID)
|
||||
removeVoteReq = testutils.WithURLParams(removeVoteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
removeVoteResp := httptest.NewRecorder()
|
||||
|
||||
voteHandler.RemoveVote(removeVoteResp, removeVoteReq)
|
||||
if removeVoteResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", removeVoteResp.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("User_Handler_Complete_Workflow", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createAuthenticatedUser(t, authService, userRepo, "user_handler_user", "user_handler@example.com")
|
||||
|
||||
usersReq := httptest.NewRequest("GET", "/api/users", nil)
|
||||
usersReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
usersReq = testutils.WithUserContext(usersReq, middleware.UserIDKey, user.User.ID)
|
||||
usersResp := httptest.NewRecorder()
|
||||
|
||||
userHandler.GetUsers(usersResp, usersReq)
|
||||
if usersResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", usersResp.Code)
|
||||
}
|
||||
|
||||
getUserReq := httptest.NewRequest("GET", fmt.Sprintf("/api/users/%d", user.User.ID), nil)
|
||||
getUserReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
getUserReq = testutils.WithUserContext(getUserReq, middleware.UserIDKey, user.User.ID)
|
||||
getUserReq = testutils.WithURLParams(getUserReq, map[string]string{"id": fmt.Sprintf("%d", user.User.ID)})
|
||||
getUserResp := httptest.NewRecorder()
|
||||
|
||||
userHandler.GetUser(getUserResp, getUserReq)
|
||||
if getUserResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", getUserResp.Code)
|
||||
}
|
||||
|
||||
getUserPostsReq := httptest.NewRequest("GET", fmt.Sprintf("/api/users/%d/posts", user.User.ID), nil)
|
||||
getUserPostsReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
getUserPostsReq = testutils.WithUserContext(getUserPostsReq, middleware.UserIDKey, user.User.ID)
|
||||
getUserPostsReq = testutils.WithURLParams(getUserPostsReq, map[string]string{"id": fmt.Sprintf("%d", user.User.ID)})
|
||||
getUserPostsResp := httptest.NewRecorder()
|
||||
|
||||
userHandler.GetUserPosts(getUserPostsResp, getUserPostsReq)
|
||||
if getUserPostsResp.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", getUserPostsResp.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Error_Handling_Invalid_Requests", func(t *testing.T) {
|
||||
invalidJSONReq := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer([]byte("invalid json")))
|
||||
invalidJSONReq.Header.Set("Content-Type", "application/json")
|
||||
invalidJSONResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Register(invalidJSONResp, invalidJSONReq)
|
||||
if invalidJSONResp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for invalid JSON, got %d", invalidJSONResp.Code)
|
||||
}
|
||||
|
||||
var jsonErrorResp map[string]any
|
||||
if err := json.Unmarshal(invalidJSONResp.Body.Bytes(), &jsonErrorResp); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
if success, _ := jsonErrorResp["success"].(bool); success {
|
||||
t.Error("Expected error response to have success=false")
|
||||
}
|
||||
if errorMsg, ok := jsonErrorResp["error"].(string); !ok || errorMsg == "" {
|
||||
t.Error("Expected error response to contain JSON parsing error message")
|
||||
}
|
||||
|
||||
missingCTData := map[string]string{
|
||||
"username": "missing_ct_user",
|
||||
"email": "missing_ct@example.com",
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
missingCTBody, _ := json.Marshal(missingCTData)
|
||||
missingCTReq := httptest.NewRequest("POST", "/api/auth/register", bytes.NewBuffer(missingCTBody))
|
||||
missingCTResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Register(missingCTResp, missingCTReq)
|
||||
if missingCTResp.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %d", missingCTResp.Code)
|
||||
}
|
||||
|
||||
invalidEndpointReq := httptest.NewRequest("GET", "/api/invalid/endpoint", nil)
|
||||
invalidEndpointResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Me(invalidEndpointResp, invalidEndpointReq)
|
||||
if invalidEndpointResp.Code == http.StatusOK {
|
||||
t.Error("Expected error for invalid endpoint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Security_Authentication_Bypass", func(t *testing.T) {
|
||||
meReq := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
meResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Me(meResp, meReq)
|
||||
if meResp.Code == http.StatusOK {
|
||||
t.Error("Expected error for unauthenticated request")
|
||||
}
|
||||
|
||||
invalidTokenReq := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
invalidTokenReq.Header.Set("Authorization", "Bearer invalid-token")
|
||||
invalidTokenResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Me(invalidTokenResp, invalidTokenReq)
|
||||
if invalidTokenResp.Code == http.StatusOK {
|
||||
t.Error("Expected error for invalid token")
|
||||
}
|
||||
|
||||
malformedTokenReq := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
malformedTokenReq.Header.Set("Authorization", "InvalidFormat token")
|
||||
malformedTokenResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Me(malformedTokenResp, malformedTokenReq)
|
||||
if malformedTokenResp.Code == http.StatusOK {
|
||||
t.Error("Expected error for malformed token")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Security_Input_Sanitization", func(t *testing.T) {
|
||||
user := createAuthenticatedUser(t, authService, userRepo, "xss_user", "xss@example.com")
|
||||
|
||||
xssData := map[string]string{
|
||||
"title": "<script>alert('xss')</script>",
|
||||
"url": "https://example.com/xss",
|
||||
"content": "XSS test content",
|
||||
}
|
||||
xssBody, _ := json.Marshal(xssData)
|
||||
xssReq := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(xssBody))
|
||||
xssReq.Header.Set("Content-Type", "application/json")
|
||||
xssReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
xssReq = testutils.WithUserContext(xssReq, middleware.UserIDKey, user.User.ID)
|
||||
xssResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.CreatePost(xssResp, xssReq)
|
||||
if xssResp.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201 for XSS sanitization, got %d", xssResp.Code)
|
||||
}
|
||||
|
||||
var xssResult map[string]any
|
||||
if err := json.Unmarshal(xssResp.Body.Bytes(), &xssResult); err != nil {
|
||||
t.Fatalf("Failed to decode XSS response: %v", err)
|
||||
}
|
||||
if success, _ := xssResult["success"].(bool); !success {
|
||||
t.Error("Expected XSS response to have success=true")
|
||||
}
|
||||
|
||||
data, ok := xssResult["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Expected data object in XSS response, got %T", xssResult["data"])
|
||||
}
|
||||
|
||||
title, ok := data["title"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("Expected title string in XSS response, got %T", data["title"])
|
||||
}
|
||||
|
||||
if strings.Contains(title, "<script>") {
|
||||
t.Errorf("Expected script tags to be HTML-escaped in title, got: %s", title)
|
||||
}
|
||||
if !strings.Contains(title, "<script>") {
|
||||
t.Errorf("Expected script tags to be HTML-escaped (<script>), got: %s", title)
|
||||
}
|
||||
if !strings.Contains(title, "alert(") {
|
||||
t.Errorf("Expected JavaScript code to be present but escaped, got: %s", title)
|
||||
}
|
||||
|
||||
content, ok := data["content"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("Expected content string in XSS response, got %T", data["content"])
|
||||
}
|
||||
|
||||
if strings.Contains(content, "<script>") {
|
||||
t.Errorf("Expected script tags to be HTML-escaped in content, got: %s", content)
|
||||
}
|
||||
|
||||
sqlData := map[string]string{
|
||||
"title": "'; DROP TABLE posts; --",
|
||||
"url": "https://example.com/sql",
|
||||
"content": "SQL injection test",
|
||||
}
|
||||
sqlBody, _ := json.Marshal(sqlData)
|
||||
sqlReq := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(sqlBody))
|
||||
sqlReq.Header.Set("Content-Type", "application/json")
|
||||
sqlReq.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
sqlReq = testutils.WithUserContext(sqlReq, middleware.UserIDKey, user.User.ID)
|
||||
sqlResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.CreatePost(sqlResp, sqlReq)
|
||||
if sqlResp.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201 for SQL injection sanitization, got %d", sqlResp.Code)
|
||||
}
|
||||
|
||||
var sqlResult map[string]any
|
||||
if err := json.Unmarshal(sqlResp.Body.Bytes(), &sqlResult); err != nil {
|
||||
t.Fatalf("Failed to decode SQL response: %v", err)
|
||||
}
|
||||
if success, _ := sqlResult["success"].(bool); !success {
|
||||
t.Error("Expected SQL response to have success=true")
|
||||
}
|
||||
|
||||
sqlResponseData, ok := sqlResult["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Expected data object in SQL response, got %T", sqlResult["data"])
|
||||
}
|
||||
|
||||
sqlResponseTitle, ok := sqlResponseData["title"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("Expected title string in SQL response, got %T", sqlResponseData["title"])
|
||||
}
|
||||
|
||||
if strings.Contains(sqlResponseTitle, "'; DROP TABLE posts; --") {
|
||||
t.Errorf("Expected SQL injection payload to be HTML-escaped in title, got: %s", sqlResponseTitle)
|
||||
}
|
||||
if !strings.Contains(sqlResponseTitle, "'") {
|
||||
t.Errorf("Expected single quotes to be HTML-escaped ('), got: %s", sqlResponseTitle)
|
||||
}
|
||||
if !strings.Contains(sqlResponseTitle, "DROP TABLE") {
|
||||
t.Errorf("Expected SQL commands to be present but escaped, got: %s", sqlResponseTitle)
|
||||
}
|
||||
|
||||
sqlResponseContent, ok := sqlResponseData["content"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("Expected content string in SQL response, got %T", sqlResponseData["content"])
|
||||
}
|
||||
|
||||
if strings.Contains(sqlResponseContent, "'; DROP TABLE posts; --") {
|
||||
t.Errorf("Expected SQL injection payload to be HTML-escaped in content, got: %s", sqlResponseContent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Authorization_User_Access_Control", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user1 := createAuthenticatedUser(t, authService, userRepo, "auth_user1", "auth1@example.com")
|
||||
user2 := createAuthenticatedUser(t, authService, userRepo, "auth_user2", "auth2@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, postRepo, user1.User.ID, "Private Post", "https://example.com/private")
|
||||
|
||||
getPostReq := httptest.NewRequest("GET", fmt.Sprintf("/api/posts/%d", post.ID), nil)
|
||||
getPostReq.Header.Set("Authorization", "Bearer "+user2.Token)
|
||||
getPostReq = testutils.WithUserContext(getPostReq, middleware.UserIDKey, user2.User.ID)
|
||||
getPostReq = testutils.WithURLParams(getPostReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
getPostResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.GetPost(getPostResp, getPostReq)
|
||||
testutils.AssertHTTPStatus(t, getPostResp, http.StatusOK)
|
||||
|
||||
updateData := map[string]string{
|
||||
"title": "Updated Title",
|
||||
}
|
||||
updateBody, _ := json.Marshal(updateData)
|
||||
updateReq := httptest.NewRequest("PUT", fmt.Sprintf("/api/posts/%d", post.ID), bytes.NewBuffer(updateBody))
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateReq.Header.Set("Authorization", "Bearer "+user2.Token)
|
||||
updateReq = testutils.WithUserContext(updateReq, middleware.UserIDKey, user2.User.ID)
|
||||
updateReq = testutils.WithURLParams(updateReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
updateResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.UpdatePost(updateResp, updateReq)
|
||||
testutils.AssertHTTPStatus(t, updateResp, http.StatusForbidden)
|
||||
|
||||
deleteReq := httptest.NewRequest("DELETE", fmt.Sprintf("/api/posts/%d", post.ID), nil)
|
||||
deleteReq.Header.Set("Authorization", "Bearer "+user2.Token)
|
||||
deleteReq = testutils.WithUserContext(deleteReq, middleware.UserIDKey, user2.User.ID)
|
||||
deleteReq = testutils.WithURLParams(deleteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
deleteResp := httptest.NewRecorder()
|
||||
|
||||
postHandler.DeletePost(deleteResp, deleteReq)
|
||||
testutils.AssertHTTPStatus(t, deleteResp, http.StatusForbidden)
|
||||
})
|
||||
|
||||
t.Run("Authorization_Vote_Access_Control", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user1 := createAuthenticatedUser(t, authService, userRepo, "vote_auth_user1", "vote_auth1@example.com")
|
||||
user2 := createAuthenticatedUser(t, authService, userRepo, "vote_auth_user2", "vote_auth2@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, postRepo, user1.User.ID, "Vote Auth Post", "https://example.com/vote-auth")
|
||||
|
||||
voteData := map[string]string{"type": "up"}
|
||||
voteBody, _ := json.Marshal(voteData)
|
||||
voteReq := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(voteBody))
|
||||
voteReq.Header.Set("Content-Type", "application/json")
|
||||
voteReq.Header.Set("Authorization", "Bearer "+user2.Token)
|
||||
voteReq = testutils.WithUserContext(voteReq, middleware.UserIDKey, user2.User.ID)
|
||||
voteReq = testutils.WithURLParams(voteReq, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
voteResp := httptest.NewRecorder()
|
||||
|
||||
voteHandler.CastVote(voteResp, voteReq)
|
||||
if voteResp.Code != http.StatusOK {
|
||||
t.Errorf("Users should be able to vote on any post, got %d", voteResp.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Authorization_Token_Expiration", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createAuthenticatedUser(t, authService, userRepo, "expire_auth_user", "expire_auth@example.com")
|
||||
|
||||
now := time.Now()
|
||||
claims := services.TokenClaims{
|
||||
UserID: user.User.ID,
|
||||
Username: user.User.Username,
|
||||
SessionVersion: user.User.SessionVersion,
|
||||
TokenType: services.TokenTypeAccess,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: testutils.AppTestConfig.JWT.Issuer,
|
||||
Audience: []string{testutils.AppTestConfig.JWT.Audience},
|
||||
IssuedAt: jwt.NewNumericDate(now.Add(-25 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(-1 * time.Hour)),
|
||||
Subject: fmt.Sprint(user.User.ID),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
expiredToken, err := token.SignedString([]byte(testutils.AppTestConfig.JWT.Secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate expired token: %v", err)
|
||||
}
|
||||
|
||||
meReq := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
meReq.Header.Set("Authorization", "Bearer "+expiredToken)
|
||||
meResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Me(meResp, meReq)
|
||||
testutils.AssertHTTPStatus(t, meResp, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Authorization_Token_Tampering", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createAuthenticatedUser(t, authService, userRepo, "tamper_user", "tamper@example.com")
|
||||
|
||||
tamperedToken := user.Token[:len(user.Token)-5] + "XXXXX"
|
||||
|
||||
meReq := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
meReq.Header.Set("Authorization", "Bearer "+tamperedToken)
|
||||
meResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Me(meResp, meReq)
|
||||
testutils.AssertHTTPStatus(t, meResp, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Authorization_Session_Version_Mismatch", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createAuthenticatedUser(t, authService, userRepo, "session_user", "session@example.com")
|
||||
|
||||
now := time.Now()
|
||||
claims := services.TokenClaims{
|
||||
UserID: user.User.ID,
|
||||
Username: user.User.Username,
|
||||
SessionVersion: user.User.SessionVersion + 1,
|
||||
TokenType: services.TokenTypeAccess,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: testutils.AppTestConfig.JWT.Issuer,
|
||||
Audience: []string{testutils.AppTestConfig.JWT.Audience},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
|
||||
Subject: fmt.Sprint(user.User.ID),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
invalidToken, err := token.SignedString([]byte(testutils.AppTestConfig.JWT.Secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate invalid token: %v", err)
|
||||
}
|
||||
|
||||
meReq := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
meReq.Header.Set("Authorization", "Bearer "+invalidToken)
|
||||
meResp := httptest.NewRecorder()
|
||||
|
||||
authHandler.Me(meResp, meReq)
|
||||
testutils.AssertHTTPStatus(t, meResp, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestIntegration_DatabaseMonitoring(t *testing.T) {
|
||||
db := testutils.NewTestDB(t)
|
||||
defer func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
}()
|
||||
|
||||
monitor := middleware.NewInMemoryDBMonitor()
|
||||
|
||||
monitoringPlugin := database.NewGormDBMonitor(monitor)
|
||||
if err := db.Use(monitoringPlugin); err != nil {
|
||||
t.Fatalf("Failed to add monitoring plugin: %v", err)
|
||||
}
|
||||
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
postRepo := repositories.NewPostRepository(db)
|
||||
voteRepo := repositories.NewVoteRepository(db)
|
||||
deletionRepo := repositories.NewAccountDeletionRepository(db)
|
||||
refreshTokenRepo := repositories.NewRefreshTokenRepository(db)
|
||||
emailSender := &testutils.MockEmailSender{}
|
||||
|
||||
_, err := services.NewAuthFacadeForTest(testutils.AppTestConfig, userRepo, postRepo, deletionRepo, refreshTokenRepo, emailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
|
||||
voteService := services.NewVoteService(voteRepo, postRepo, db)
|
||||
|
||||
apiHandler := handlers.NewAPIHandlerWithMonitoring(testutils.AppTestConfig, postRepo, userRepo, voteService, db, monitor)
|
||||
|
||||
t.Run("Health endpoint includes database monitoring", func(t *testing.T) {
|
||||
|
||||
user := &database.User{
|
||||
Username: "monitoring_user",
|
||||
Email: "monitoring@example.com",
|
||||
Password: "password123",
|
||||
EmailVerified: true,
|
||||
}
|
||||
userRepo.Create(user)
|
||||
|
||||
request := httptest.NewRequest("GET", "/health", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
apiHandler.GetHealth(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
var response map[string]any
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response["success"] != true {
|
||||
t.Error("Expected success to be true")
|
||||
}
|
||||
|
||||
data, ok := response["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Expected data to be a map")
|
||||
}
|
||||
|
||||
if pingTime, exists := data["ping_time"]; exists {
|
||||
t.Logf("Database ping time: %v", pingTime)
|
||||
}
|
||||
|
||||
if dbStats, exists := data["database_stats"]; exists {
|
||||
t.Logf("Database stats present: %v", dbStats)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Metrics endpoint includes database monitoring", func(t *testing.T) {
|
||||
|
||||
user := &database.User{
|
||||
Username: "metrics_user",
|
||||
Email: "metrics@example.com",
|
||||
Password: "password123",
|
||||
EmailVerified: true,
|
||||
}
|
||||
userRepo.Create(user)
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Test Post",
|
||||
Content: "Test content",
|
||||
URL: "https://example.com",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
postRepo.Create(post)
|
||||
|
||||
request := httptest.NewRequest("GET", "/metrics", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
apiHandler.GetMetrics(recorder, request)
|
||||
|
||||
testutils.AssertHTTPStatus(t, recorder, http.StatusOK)
|
||||
|
||||
var response map[string]any
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response["success"] != true {
|
||||
t.Error("Expected success to be true")
|
||||
}
|
||||
|
||||
data, ok := response["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Expected data to be a map")
|
||||
}
|
||||
|
||||
if dbData, exists := data["database"]; exists {
|
||||
t.Logf("Database monitoring data present: %v", dbData)
|
||||
if dbMap, ok := dbData.(map[string]any); ok {
|
||||
if totalQueries, exists := dbMap["total_queries"]; exists {
|
||||
t.Logf("Total queries tracked: %v", totalQueries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if perfData, exists := data["performance"]; exists {
|
||||
t.Logf("Performance data present: %v", perfData)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Database operations are tracked", func(t *testing.T) {
|
||||
|
||||
monitor = middleware.NewInMemoryDBMonitor()
|
||||
monitoringPlugin = database.NewGormDBMonitor(monitor)
|
||||
db.Use(monitoringPlugin)
|
||||
|
||||
apiHandler = handlers.NewAPIHandlerWithMonitoring(testutils.AppTestConfig, postRepo, userRepo, voteService, db, monitor)
|
||||
|
||||
user := &database.User{
|
||||
Username: "tracking_user",
|
||||
Email: "tracking@example.com",
|
||||
Password: "password123",
|
||||
EmailVerified: true,
|
||||
}
|
||||
userRepo.Create(user)
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Tracking Post",
|
||||
Content: "Tracking content",
|
||||
URL: "https://example.com",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
postRepo.Create(post)
|
||||
|
||||
stats := monitor.GetStats()
|
||||
|
||||
t.Logf("Database operations tracked: %d queries", stats.TotalQueries)
|
||||
t.Logf("Slow queries: %d", stats.SlowQueries)
|
||||
t.Logf("Average duration: %v", stats.AverageDuration)
|
||||
t.Logf("Error count: %d", stats.ErrorCount)
|
||||
|
||||
if stats.TotalQueries > 0 {
|
||||
t.Logf("✅ Database monitoring is working - tracked %d queries", stats.TotalQueries)
|
||||
} else {
|
||||
t.Logf("⚠️ Database monitoring plugin may not be tracking all operations (this is a known limitation)")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/handlers"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/server"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
type testContext struct {
|
||||
Router http.Handler
|
||||
Suite *testutils.ServiceSuite
|
||||
AuthService *services.AuthFacade
|
||||
}
|
||||
|
||||
func setupTestContext(t *testing.T) *testContext {
|
||||
t.Helper()
|
||||
middleware.StopAllRateLimiters()
|
||||
suite := testutils.NewServiceSuite(t)
|
||||
|
||||
authService, err := services.NewAuthFacadeForTest(testutils.AppTestConfig, suite.UserRepo, suite.PostRepo, suite.DeletionRepo, suite.RefreshTokenRepo, suite.EmailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
|
||||
voteService := services.NewVoteService(suite.VoteRepo, suite.PostRepo, suite.DB)
|
||||
metadataService := suite.TitleFetcher
|
||||
|
||||
authHandler := handlers.NewAuthHandler(authService, suite.UserRepo)
|
||||
postHandler := handlers.NewPostHandler(suite.PostRepo, metadataService, voteService)
|
||||
voteHandler := handlers.NewVoteHandler(voteService)
|
||||
userHandler := handlers.NewUserHandler(suite.UserRepo, authService)
|
||||
apiHandler := handlers.NewAPIHandlerWithMonitoring(testutils.AppTestConfig, suite.PostRepo, suite.UserRepo, voteService, suite.DB, middleware.NewInMemoryDBMonitor())
|
||||
|
||||
staticDir := t.TempDir()
|
||||
robotsFile := filepath.Join(staticDir, "robots.txt")
|
||||
os.WriteFile(robotsFile, []byte("User-agent: *\nDisallow: /"), 0644)
|
||||
|
||||
router := server.NewRouter(server.RouterConfig{
|
||||
AuthHandler: authHandler,
|
||||
PostHandler: postHandler,
|
||||
VoteHandler: voteHandler,
|
||||
UserHandler: userHandler,
|
||||
APIHandler: apiHandler,
|
||||
AuthService: authService,
|
||||
PageHandler: nil,
|
||||
StaticDir: staticDir,
|
||||
Debug: false,
|
||||
DisableCache: false,
|
||||
DisableCompression: false,
|
||||
DBMonitor: middleware.NewInMemoryDBMonitor(),
|
||||
RateLimitConfig: testutils.AppTestConfig.RateLimit,
|
||||
})
|
||||
|
||||
return &testContext{
|
||||
Router: router,
|
||||
Suite: suite,
|
||||
AuthService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func setupPageHandlerTestContext(t *testing.T) *testContext {
|
||||
t.Helper()
|
||||
middleware.StopAllRateLimiters()
|
||||
suite := testutils.NewServiceSuite(t)
|
||||
|
||||
authService, err := services.NewAuthFacadeForTest(testutils.AppTestConfig, suite.UserRepo, suite.PostRepo, suite.DeletionRepo, suite.RefreshTokenRepo, suite.EmailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
|
||||
voteService := services.NewVoteService(suite.VoteRepo, suite.PostRepo, suite.DB)
|
||||
metadataService := suite.TitleFetcher
|
||||
|
||||
authHandler := handlers.NewAuthHandler(authService, suite.UserRepo)
|
||||
postHandler := handlers.NewPostHandler(suite.PostRepo, metadataService, voteService)
|
||||
voteHandler := handlers.NewVoteHandler(voteService)
|
||||
userHandler := handlers.NewUserHandler(suite.UserRepo, authService)
|
||||
apiHandler := handlers.NewAPIHandler(testutils.AppTestConfig, suite.PostRepo, suite.UserRepo, voteService)
|
||||
|
||||
staticDir := t.TempDir()
|
||||
templatesDir := t.TempDir()
|
||||
|
||||
baseTemplate := `{{define "layout"}}<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Title}}</title>
|
||||
</head>
|
||||
<body>
|
||||
{{block "content" .}}{{end}}
|
||||
</body>
|
||||
</html>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "base.gohtml"), []byte(baseTemplate), 0644)
|
||||
|
||||
os.MkdirAll(filepath.Join(templatesDir, "partials"), 0755)
|
||||
|
||||
homeTemplate := `{{define "content"}}<h1>Home</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "home.gohtml"), []byte(homeTemplate), 0644)
|
||||
|
||||
loginTemplate := `{{define "content"}}<h1>Login</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "login.gohtml"), []byte(loginTemplate), 0644)
|
||||
|
||||
registerTemplate := `{{define "content"}}<h1>Register</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "register.gohtml"), []byte(registerTemplate), 0644)
|
||||
|
||||
settingsTemplate := `{{define "content"}}<h1>Settings</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "settings.gohtml"), []byte(settingsTemplate), 0644)
|
||||
|
||||
postTemplate := `{{define "content"}}<h1>{{.Post.Title}}</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "post.gohtml"), []byte(postTemplate), 0644)
|
||||
|
||||
errorTemplate := `{{define "content"}}<h1>Error</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "error.gohtml"), []byte(errorTemplate), 0644)
|
||||
|
||||
confirmTemplate := `{{define "content"}}<h1>Confirm</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "confirm.gohtml"), []byte(confirmTemplate), 0644)
|
||||
|
||||
confirmEmailTemplate := `{{define "content"}}<h1>Confirm Email</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "confirm_email.gohtml"), []byte(confirmEmailTemplate), 0644)
|
||||
|
||||
resendTemplate := `{{define "content"}}<h1>Resend</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "resend-verification.gohtml"), []byte(resendTemplate), 0644)
|
||||
|
||||
resendVerificationTemplate := `{{define "content"}}<h1>Resend Verification</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "resend_verification.gohtml"), []byte(resendVerificationTemplate), 0644)
|
||||
|
||||
forgotTemplate := `{{define "content"}}<h1>Forgot Password</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "forgot-password.gohtml"), []byte(forgotTemplate), 0644)
|
||||
|
||||
forgotPasswordTemplate := `{{define "content"}}<h1>Forgot Password</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "forgot_password.gohtml"), []byte(forgotPasswordTemplate), 0644)
|
||||
|
||||
resetTemplate := `{{define "content"}}<h1>Reset Password</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "reset-password.gohtml"), []byte(resetTemplate), 0644)
|
||||
|
||||
resetPasswordTemplate := `{{define "content"}}<h1>Reset Password</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "reset_password.gohtml"), []byte(resetPasswordTemplate), 0644)
|
||||
|
||||
searchTemplate := `{{define "content"}}<h1>Search</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "search.gohtml"), []byte(searchTemplate), 0644)
|
||||
|
||||
newPostTemplate := `{{define "content"}}<h1>New Post</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "new-post.gohtml"), []byte(newPostTemplate), 0644)
|
||||
|
||||
newPostTemplate2 := `{{define "content"}}<h1>New Post</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "new_post.gohtml"), []byte(newPostTemplate2), 0644)
|
||||
|
||||
confirmDeleteTemplate := `{{define "content"}}<h1>Confirm Delete</h1>{{end}}`
|
||||
os.WriteFile(filepath.Join(templatesDir, "confirm_delete.gohtml"), []byte(confirmDeleteTemplate), 0644)
|
||||
|
||||
pageHandler, err := handlers.NewPageHandler(templatesDir, authService, suite.PostRepo, voteService, suite.UserRepo, metadataService, testutils.AppTestConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create page handler: %v", err)
|
||||
}
|
||||
|
||||
router := server.NewRouter(server.RouterConfig{
|
||||
AuthHandler: authHandler,
|
||||
PostHandler: postHandler,
|
||||
VoteHandler: voteHandler,
|
||||
UserHandler: userHandler,
|
||||
APIHandler: apiHandler,
|
||||
AuthService: authService,
|
||||
PageHandler: pageHandler,
|
||||
StaticDir: staticDir,
|
||||
Debug: false,
|
||||
DisableCache: false,
|
||||
DisableCompression: false,
|
||||
DBMonitor: middleware.NewInMemoryDBMonitor(),
|
||||
RateLimitConfig: testutils.AppTestConfig.RateLimit,
|
||||
})
|
||||
|
||||
return &testContext{
|
||||
Router: router,
|
||||
Suite: suite,
|
||||
AuthService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func getCSRFToken(t *testing.T, router http.Handler, path string, cookies ...*http.Cookie) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
cookieList := rec.Result().Cookies()
|
||||
for _, cookie := range cookieList {
|
||||
if cookie.Name == "csrf_token" {
|
||||
return cookie.Value
|
||||
}
|
||||
}
|
||||
t.Fatal("CSRF token not found")
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertJSONResponse(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) map[string]any {
|
||||
t.Helper()
|
||||
if rec.Code != expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d. Body: %s", expectedStatus, rec.Code, rec.Body.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
var response map[string]any
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v. Body: %s", err, rec.Body.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func assertErrorResponse(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) {
|
||||
t.Helper()
|
||||
if rec.Code != expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d. Body: %s", expectedStatus, rec.Code, rec.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
var response map[string]any
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v. Body: %s", err, rec.Body.String())
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := response["error"]; !ok {
|
||||
if _, ok := response["message"]; !ok {
|
||||
t.Error("Expected error or message field in error response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatus(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) {
|
||||
t.Helper()
|
||||
if rec.Code != expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d. Body: %s", expectedStatus, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatusRange(t *testing.T, rec *httptest.ResponseRecorder, minStatus, maxStatus int) {
|
||||
t.Helper()
|
||||
if rec.Code < minStatus || rec.Code > maxStatus {
|
||||
t.Errorf("Expected status between %d and %d, got %d. Body: %s", minStatus, maxStatus, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func assertCookie(t *testing.T, rec *httptest.ResponseRecorder, name, expectedValue string) {
|
||||
t.Helper()
|
||||
cookies := rec.Result().Cookies()
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
if expectedValue != "" && cookie.Value != expectedValue {
|
||||
t.Errorf("Expected cookie %s value %s, got %s", name, expectedValue, cookie.Value)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("Expected cookie %s not found", name)
|
||||
}
|
||||
|
||||
func assertCookieCleared(t *testing.T, rec *httptest.ResponseRecorder, name string) {
|
||||
t.Helper()
|
||||
cookies := rec.Result().Cookies()
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
if cookie.Value != "" {
|
||||
t.Errorf("Expected cookie %s to be cleared, got value %s", name, cookie.Value)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertHeader(t *testing.T, rec *httptest.ResponseRecorder, name, expectedValue string) {
|
||||
t.Helper()
|
||||
actualValue := rec.Header().Get(name)
|
||||
if actualValue != expectedValue {
|
||||
t.Errorf("Expected header %s=%s, got %s", name, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHeaderContains(t *testing.T, rec *httptest.ResponseRecorder, name, substring string) {
|
||||
t.Helper()
|
||||
actualValue := rec.Header().Get(name)
|
||||
if !strings.Contains(actualValue, substring) {
|
||||
t.Errorf("Expected header %s to contain %s, got %s", name, substring, actualValue)
|
||||
}
|
||||
}
|
||||
|
||||
type authenticatedUser struct {
|
||||
User *database.User
|
||||
Token string
|
||||
}
|
||||
|
||||
func createAuthenticatedUser(t *testing.T, authService *services.AuthFacade, userRepo repositories.UserRepository, username, email string) *authenticatedUser {
|
||||
t.Helper()
|
||||
|
||||
password := "SecurePass123!"
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
user := &database.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: string(hashedPassword),
|
||||
EmailVerified: true,
|
||||
}
|
||||
|
||||
if err := userRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create authenticated user: %v", err)
|
||||
}
|
||||
|
||||
loginResult, err := authService.Login(username, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login authenticated user: %v", err)
|
||||
}
|
||||
|
||||
return &authenticatedUser{
|
||||
User: loginResult.User,
|
||||
Token: loginResult.AccessToken,
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueTestUsername(t *testing.T, prefix string) string {
|
||||
return fmt.Sprintf("%s_%d_%d", prefix, time.Now().UnixNano(), len(t.Name()))
|
||||
}
|
||||
|
||||
func uniqueTestEmail(t *testing.T, prefix string) string {
|
||||
return fmt.Sprintf("%s_%d_%d@example.com", prefix, time.Now().UnixNano(), len(t.Name()))
|
||||
}
|
||||
|
||||
func createUserWithCleanup(t *testing.T, ctx *testContext, username, email string) *authenticatedUser {
|
||||
t.Helper()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, username, email)
|
||||
t.Cleanup(func() {
|
||||
if err := ctx.Suite.UserRepo.Delete(user.User.ID); err != nil {
|
||||
t.Logf("Failed to cleanup user %d: %v", user.User.ID, err)
|
||||
}
|
||||
})
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_PageHandlerFormWorkflows(t *testing.T) {
|
||||
ctx := setupPageHandlerTestContext(t)
|
||||
router := ctx.Router
|
||||
authService := ctx.AuthService
|
||||
|
||||
t.Run("Settings_Email_Update_Form", func(t *testing.T) {
|
||||
middleware.StopAllRateLimiters()
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, authService, ctx.Suite.UserRepo, "settings_email_user", "settings_email@example.com")
|
||||
|
||||
getReq := httptest.NewRequest("GET", "/settings", nil)
|
||||
getReq.AddCookie(&http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
|
||||
csrfToken := getCSRFToken(t, router, "/settings", &http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("email", "newemail@example.com")
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/settings/email", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusSeeOther)
|
||||
})
|
||||
|
||||
t.Run("Settings_Username_Update_Form", func(t *testing.T) {
|
||||
middleware.StopAllRateLimiters()
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, authService, ctx.Suite.UserRepo, "settings_username_user", "settings_username@example.com")
|
||||
|
||||
csrfToken := getCSRFToken(t, router, "/settings", &http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("username", "new_username")
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/settings/username", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusSeeOther)
|
||||
})
|
||||
|
||||
t.Run("Settings_Password_Update_Form", func(t *testing.T) {
|
||||
middleware.StopAllRateLimiters()
|
||||
freshCtx := setupPageHandlerTestContext(t)
|
||||
freshCtx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, freshCtx.AuthService, freshCtx.Suite.UserRepo, "settings_password_user", "settings_password@example.com")
|
||||
|
||||
csrfToken := getCSRFToken(t, freshCtx.Router, "/settings", &http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("current_password", "SecurePass123!")
|
||||
reqBody.Set("new_password", "NewSecurePass123!")
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/settings/password", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
freshCtx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusSeeOther)
|
||||
})
|
||||
|
||||
t.Run("Logout_Page_Handler", func(t *testing.T) {
|
||||
middleware.StopAllRateLimiters()
|
||||
freshCtx := setupPageHandlerTestContext(t)
|
||||
freshCtx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, freshCtx.AuthService, freshCtx.Suite.UserRepo, "logout_page_user", "logout_page@example.com")
|
||||
|
||||
csrfToken := getCSRFToken(t, freshCtx.Router, "/settings", &http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/logout", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
freshCtx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusSeeOther)
|
||||
assertCookieCleared(t, rec, "auth_token")
|
||||
})
|
||||
|
||||
t.Run("Resend_Verification_Page_Handler", func(t *testing.T) {
|
||||
middleware.StopAllRateLimiters()
|
||||
freshCtx := setupPageHandlerTestContext(t)
|
||||
freshCtx.Suite.EmailSender.Reset()
|
||||
|
||||
csrfToken := getCSRFToken(t, freshCtx.Router, "/resend-verification")
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("email", "resend_page@example.com")
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/resend-verification", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
freshCtx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusSeeOther)
|
||||
})
|
||||
|
||||
t.Run("Post_Vote_Page_Handler", func(t *testing.T) {
|
||||
middleware.StopAllRateLimiters()
|
||||
freshCtx := setupPageHandlerTestContext(t)
|
||||
freshCtx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, freshCtx.AuthService, freshCtx.Suite.UserRepo, "vote_page_user", "vote_page@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, freshCtx.Suite.PostRepo, user.User.ID, "Vote Page Test", "https://example.com/vote-page")
|
||||
|
||||
getReq := httptest.NewRequest("GET", fmt.Sprintf("/posts/%d", post.ID), nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
freshCtx.Router.ServeHTTP(getRec, getReq)
|
||||
|
||||
csrfToken := getCSRFToken(t, freshCtx.Router, fmt.Sprintf("/posts/%d", post.ID))
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("action", "up")
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/posts/%d/vote", post.ID), strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
freshCtx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusSeeOther)
|
||||
})
|
||||
|
||||
t.Run("Login_Page_Handler_Workflow", func(t *testing.T) {
|
||||
middleware.StopAllRateLimiters()
|
||||
freshCtx := setupPageHandlerTestContext(t)
|
||||
freshCtx.Suite.EmailSender.Reset()
|
||||
createAuthenticatedUser(t, freshCtx.AuthService, freshCtx.Suite.UserRepo, "login_page_user", "login_page@example.com")
|
||||
|
||||
csrfToken := getCSRFToken(t, freshCtx.Router, "/login")
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("username", "login_page_user")
|
||||
reqBody.Set("password", "SecurePass123!")
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/login", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
freshCtx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusSeeOther)
|
||||
cookies := rec.Result().Cookies()
|
||||
authCookieSet := false
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "auth_token" && cookie.Value != "" {
|
||||
authCookieSet = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !authCookieSet {
|
||||
t.Error("Expected auth cookie to be set on login")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Email_Confirmation_Page_Handler", func(t *testing.T) {
|
||||
middleware.StopAllRateLimiters()
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
createAuthenticatedUser(t, authService, ctx.Suite.UserRepo, "confirm_page_user", "confirm_page@example.com")
|
||||
|
||||
token := ctx.Suite.EmailSender.VerificationToken()
|
||||
if token == "" {
|
||||
token = "test-token"
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/confirm?token="+url.QueryEscape(token), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_PageHandler(t *testing.T) {
|
||||
ctx := setupPageHandlerTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("Home_Page_Renders", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "<html") {
|
||||
t.Error("Expected HTML content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Login_Form_Renders", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/login", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "login") && !strings.Contains(body, "Login") {
|
||||
t.Error("Expected login form content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Register_Form_Renders", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/register", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "register") && !strings.Contains(body, "Register") {
|
||||
t.Error("Expected register form content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PageHandler_With_CSRF_Token", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/register", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
cookies := rec.Result().Cookies()
|
||||
csrfFound := false
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "csrf_token" {
|
||||
csrfFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !csrfFound {
|
||||
t.Error("Expected CSRF token cookie to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PageHandler_Form_Submission", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
getReq := httptest.NewRequest("GET", "/register", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
|
||||
cookies := getRec.Result().Cookies()
|
||||
var csrfCookie *http.Cookie
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == "csrf_token" {
|
||||
csrfCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if csrfCookie == nil {
|
||||
t.Fatal("Expected CSRF cookie")
|
||||
}
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("username", "page_form_user")
|
||||
reqBody.Set("email", "page_form@example.com")
|
||||
reqBody.Set("password", "SecurePass123!")
|
||||
reqBody.Set("csrf_token", csrfCookie.Value)
|
||||
|
||||
req := httptest.NewRequest("POST", "/register", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(csrfCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusSeeOther)
|
||||
})
|
||||
|
||||
t.Run("PageHandler_Authenticated_Access", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "page_auth_user", "page_auth@example.com")
|
||||
|
||||
req := httptest.NewRequest("GET", "/settings", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "auth_token", Value: user.Token})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("PageHandler_Post_Display", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "page_post_user", "page_post@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Page Test Post", "https://example.com/page-test")
|
||||
|
||||
req := httptest.NewRequest("GET", "/posts/"+fmt.Sprintf("%d", post.ID), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Page Test Post") {
|
||||
t.Error("Expected post title in page")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PageHandler_Search_Page", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/search?q=test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("PageHandler_Error_Handling", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/nonexistent", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_PasswordReset_CompleteFlow(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("API_PasswordReset_Request", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "reset_user",
|
||||
Email: "reset@example.com",
|
||||
Password: testutils.HashPassword("OldPassword123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := ctx.Suite.UserRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
reqBody := map[string]string{
|
||||
"username_or_email": "reset_user",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/forgot-password", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if success, ok := response["success"].(bool); !ok || !success {
|
||||
t.Error("Expected success=true")
|
||||
}
|
||||
}
|
||||
|
||||
resetToken := ctx.Suite.EmailSender.PasswordResetToken()
|
||||
if resetToken == "" {
|
||||
t.Error("Expected password reset token to be generated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("API_PasswordReset_Complete", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "reset_complete_user",
|
||||
Email: "reset_complete@example.com",
|
||||
Password: testutils.HashPassword("OldPassword123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := ctx.Suite.UserRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
if err := ctx.AuthService.RequestPasswordReset("reset_complete_user"); err != nil {
|
||||
t.Fatalf("Failed to request password reset: %v", err)
|
||||
}
|
||||
|
||||
resetToken := ctx.Suite.EmailSender.PasswordResetToken()
|
||||
if resetToken == "" {
|
||||
t.Fatal("Expected password reset token")
|
||||
}
|
||||
|
||||
reqBody := map[string]string{
|
||||
"token": resetToken,
|
||||
"new_password": "NewPassword123!",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/reset-password", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
loginResult, err := ctx.AuthService.Login("reset_complete_user", "NewPassword123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login with new password: %v", err)
|
||||
}
|
||||
|
||||
if loginResult.User.Username != "reset_complete_user" {
|
||||
t.Error("Expected login to succeed with new password")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Page_PasswordReset_Request", func(t *testing.T) {
|
||||
pageCtx := setupPageHandlerTestContext(t)
|
||||
pageRouter := pageCtx.Router
|
||||
pageCtx.Suite.EmailSender.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "page_reset_user",
|
||||
Email: "page_reset@example.com",
|
||||
Password: testutils.HashPassword("OldPassword123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := pageCtx.Suite.UserRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
csrfToken := getCSRFToken(t, pageRouter, "/forgot-password")
|
||||
|
||||
reqBody := url.Values{}
|
||||
reqBody.Set("username_or_email", "page_reset_user")
|
||||
reqBody.Set("csrf_token", csrfToken)
|
||||
req := httptest.NewRequest("POST", "/forgot-password", strings.NewReader(reqBody.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
pageRouter.ServeHTTP(rec, req)
|
||||
|
||||
assertStatusRange(t, rec, http.StatusOK, http.StatusSeeOther)
|
||||
|
||||
resetToken := pageCtx.Suite.EmailSender.PasswordResetToken()
|
||||
if resetToken == "" {
|
||||
t.Error("Expected password reset token to be generated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PasswordReset_TokenExpiration", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "expire_user",
|
||||
Email: "expire@example.com",
|
||||
Password: testutils.HashPassword("OldPassword123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := ctx.Suite.UserRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
if err := ctx.AuthService.RequestPasswordReset("expire_user"); err != nil {
|
||||
t.Fatalf("Failed to request password reset: %v", err)
|
||||
}
|
||||
|
||||
resetToken := ctx.Suite.EmailSender.PasswordResetToken()
|
||||
hashedToken := testutils.HashVerificationToken(resetToken)
|
||||
|
||||
user, err := ctx.Suite.UserRepo.GetByPasswordResetToken(hashedToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
expiredTime := time.Now().Add(-25 * time.Hour)
|
||||
user.PasswordResetExpiresAt = &expiredTime
|
||||
if err := ctx.Suite.UserRepo.Update(user); err != nil {
|
||||
t.Fatalf("Failed to update user: %v", err)
|
||||
}
|
||||
|
||||
reqBody := map[string]string{
|
||||
"token": resetToken,
|
||||
"new_password": "NewPassword123!",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/reset-password", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("PasswordReset_InvalidToken", func(t *testing.T) {
|
||||
reqBody := map[string]string{
|
||||
"token": "invalid-token",
|
||||
"new_password": "NewPassword123!",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/reset-password", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("PasswordReset_WeakPassword", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "weak_pass_user",
|
||||
Email: "weak_pass@example.com",
|
||||
Password: testutils.HashPassword("OldPassword123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := ctx.Suite.UserRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
if err := ctx.AuthService.RequestPasswordReset("weak_pass_user"); err != nil {
|
||||
t.Fatalf("Failed to request password reset: %v", err)
|
||||
}
|
||||
|
||||
resetToken := ctx.Suite.EmailSender.PasswordResetToken()
|
||||
|
||||
reqBody := map[string]string{
|
||||
"token": resetToken,
|
||||
"new_password": "123",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/reset-password", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusBadRequest)
|
||||
})
|
||||
|
||||
t.Run("PasswordReset_EmailIntegration", func(t *testing.T) {
|
||||
middleware.StopAllRateLimiters()
|
||||
freshCtx := setupTestContext(t)
|
||||
freshCtx.Suite.EmailSender.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "email_reset_user",
|
||||
Email: "email_reset@example.com",
|
||||
Password: testutils.HashPassword("OldPassword123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := freshCtx.Suite.UserRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
reqBody := map[string]string{
|
||||
"username_or_email": "email_reset@example.com",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/forgot-password", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
freshCtx.Router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
resetToken := freshCtx.Suite.EmailSender.PasswordResetToken()
|
||||
if resetToken == "" {
|
||||
t.Error("Expected password reset token when using email")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/config"
|
||||
"goyco/internal/handlers"
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/server"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func setupRateLimitRouter(t *testing.T, rateLimitConfig config.RateLimitConfig) (http.Handler, *testutils.ServiceSuite) {
|
||||
t.Helper()
|
||||
suite := testutils.NewServiceSuite(t)
|
||||
|
||||
authService, err := services.NewAuthFacadeForTest(testutils.AppTestConfig, suite.UserRepo, suite.PostRepo, suite.DeletionRepo, suite.RefreshTokenRepo, suite.EmailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
|
||||
voteService := services.NewVoteService(suite.VoteRepo, suite.PostRepo, suite.DB)
|
||||
metadataService := services.NewURLMetadataService()
|
||||
|
||||
authHandler := handlers.NewAuthHandler(authService, suite.UserRepo)
|
||||
postHandler := handlers.NewPostHandler(suite.PostRepo, metadataService, voteService)
|
||||
voteHandler := handlers.NewVoteHandler(voteService)
|
||||
userHandler := handlers.NewUserHandler(suite.UserRepo, authService)
|
||||
apiHandler := handlers.NewAPIHandlerWithMonitoring(testutils.AppTestConfig, suite.PostRepo, suite.UserRepo, voteService, suite.DB, middleware.NewInMemoryDBMonitor())
|
||||
|
||||
staticDir := t.TempDir()
|
||||
|
||||
router := server.NewRouter(server.RouterConfig{
|
||||
AuthHandler: authHandler,
|
||||
PostHandler: postHandler,
|
||||
VoteHandler: voteHandler,
|
||||
UserHandler: userHandler,
|
||||
APIHandler: apiHandler,
|
||||
AuthService: authService,
|
||||
PageHandler: nil,
|
||||
StaticDir: staticDir,
|
||||
Debug: false,
|
||||
DisableCache: false,
|
||||
DisableCompression: false,
|
||||
DBMonitor: middleware.NewInMemoryDBMonitor(),
|
||||
RateLimitConfig: rateLimitConfig,
|
||||
})
|
||||
|
||||
return router, suite
|
||||
}
|
||||
|
||||
func TestIntegration_RateLimiting(t *testing.T) {
|
||||
t.Run("Auth_RateLimit_Enforced", func(t *testing.T) {
|
||||
rateLimitConfig := testutils.AppTestConfig.RateLimit
|
||||
rateLimitConfig.AuthLimit = 2
|
||||
router, _ := setupRateLimitRouter(t, rateLimitConfig)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBufferString(`{"username":"test","password":"test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBufferString(`{"username":"test","password":"test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusTooManyRequests)
|
||||
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Error("Expected Retry-After header")
|
||||
}
|
||||
|
||||
var response map[string]any
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err == nil {
|
||||
if _, exists := response["retry_after"]; !exists {
|
||||
t.Error("Expected retry_after in response")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("General_RateLimit_Enforced", func(t *testing.T) {
|
||||
rateLimitConfig := testutils.AppTestConfig.RateLimit
|
||||
rateLimitConfig.GeneralLimit = 5
|
||||
router, _ := setupRateLimitRouter(t, rateLimitConfig)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
req := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusTooManyRequests)
|
||||
})
|
||||
|
||||
t.Run("Health_RateLimit_Enforced", func(t *testing.T) {
|
||||
rateLimitConfig := testutils.AppTestConfig.RateLimit
|
||||
rateLimitConfig.HealthLimit = 3
|
||||
router, _ := setupRateLimitRouter(t, rateLimitConfig)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusTooManyRequests)
|
||||
})
|
||||
|
||||
t.Run("Metrics_RateLimit_Enforced", func(t *testing.T) {
|
||||
rateLimitConfig := testutils.AppTestConfig.RateLimit
|
||||
rateLimitConfig.MetricsLimit = 2
|
||||
router, _ := setupRateLimitRouter(t, rateLimitConfig)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusTooManyRequests)
|
||||
})
|
||||
|
||||
t.Run("RateLimit_Different_Endpoints_Independent", func(t *testing.T) {
|
||||
rateLimitConfig := testutils.AppTestConfig.RateLimit
|
||||
rateLimitConfig.AuthLimit = 2
|
||||
rateLimitConfig.GeneralLimit = 10
|
||||
router, _ := setupRateLimitRouter(t, rateLimitConfig)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBufferString(`{"username":"test","password":"test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("RateLimit_With_Authentication", func(t *testing.T) {
|
||||
rateLimitConfig := testutils.AppTestConfig.RateLimit
|
||||
rateLimitConfig.GeneralLimit = 3
|
||||
router, suite := setupRateLimitRouter(t, rateLimitConfig)
|
||||
|
||||
authService, err := services.NewAuthFacadeForTest(testutils.AppTestConfig, suite.UserRepo, suite.PostRepo, suite.DeletionRepo, suite.RefreshTokenRepo, suite.EmailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
|
||||
suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, authService, suite.UserRepo, uniqueTestUsername(t, "ratelimit_auth"), uniqueTestEmail(t, "ratelimit_auth"))
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
req := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertErrorResponse(t, rec, http.StatusTooManyRequests)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/repositories"
|
||||
)
|
||||
|
||||
func TestIntegration_Repositories(t *testing.T) {
|
||||
suite := repositories.NewTestSuite(t)
|
||||
|
||||
t.Run("User_Lifecycle", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "lifecycle_user",
|
||||
Email: "lifecycle@example.com",
|
||||
Password: hashPassword("SecurePass123!"),
|
||||
EmailVerified: false,
|
||||
}
|
||||
err := suite.UserRepo.Create(user)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
retrieved, err := suite.UserRepo.GetByID(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve user: %v", err)
|
||||
}
|
||||
if retrieved.Username != "lifecycle_user" {
|
||||
t.Errorf("Expected username 'lifecycle_user', got '%s'", retrieved.Username)
|
||||
}
|
||||
|
||||
retrieved.EmailVerified = true
|
||||
err = suite.UserRepo.Update(retrieved)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update user: %v", err)
|
||||
}
|
||||
|
||||
updated, err := suite.UserRepo.GetByID(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve updated user: %v", err)
|
||||
}
|
||||
if !updated.EmailVerified {
|
||||
t.Error("Expected email to be verified")
|
||||
}
|
||||
|
||||
err = suite.UserRepo.Delete(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete user: %v", err)
|
||||
}
|
||||
|
||||
_, err = suite.UserRepo.GetByID(user.ID)
|
||||
if err == nil {
|
||||
t.Error("Expected user to be deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Post_Lifecycle", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "post_author",
|
||||
Email: "author@example.com",
|
||||
Password: hashPassword("SecurePass123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err := suite.UserRepo.Create(user)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Integration Test Post",
|
||||
URL: "https://example.com/integration-test",
|
||||
Content: "This is a comprehensive integration test post",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
err = suite.PostRepo.Create(post)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create post: %v", err)
|
||||
}
|
||||
|
||||
retrieved, err := suite.PostRepo.GetByID(post.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve post: %v", err)
|
||||
}
|
||||
if retrieved.Title != "Integration Test Post" {
|
||||
t.Errorf("Expected title 'Integration Test Post', got '%s'", retrieved.Title)
|
||||
}
|
||||
|
||||
retrieved.Title = "Updated Integration Test Post"
|
||||
err = suite.PostRepo.Update(retrieved)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update post: %v", err)
|
||||
}
|
||||
|
||||
updated, err := suite.PostRepo.GetByID(post.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve updated post: %v", err)
|
||||
}
|
||||
if updated.Title != "Updated Integration Test Post" {
|
||||
t.Errorf("Expected updated title, got '%s'", updated.Title)
|
||||
}
|
||||
|
||||
err = suite.PostRepo.Delete(post.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete post: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Vote_Lifecycle", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "voter",
|
||||
Email: "voter@example.com",
|
||||
Password: hashPassword("SecurePass123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err := suite.UserRepo.Create(user)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Vote Test Post",
|
||||
URL: "https://example.com/vote-test",
|
||||
Content: "Vote test content",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
err = suite.PostRepo.Create(post)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create post: %v", err)
|
||||
}
|
||||
|
||||
vote := &database.Vote{
|
||||
UserID: &user.ID,
|
||||
PostID: post.ID,
|
||||
Type: database.VoteUp,
|
||||
}
|
||||
err = suite.VoteRepo.Create(vote)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vote: %v", err)
|
||||
}
|
||||
|
||||
retrieved, err := suite.VoteRepo.GetByID(vote.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve vote: %v", err)
|
||||
}
|
||||
if retrieved.Type != database.VoteUp {
|
||||
t.Errorf("Expected vote type %v, got %v", database.VoteUp, retrieved.Type)
|
||||
}
|
||||
|
||||
retrieved.Type = database.VoteDown
|
||||
err = suite.VoteRepo.Update(retrieved)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update vote: %v", err)
|
||||
}
|
||||
|
||||
updated, err := suite.VoteRepo.GetByID(vote.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve updated vote: %v", err)
|
||||
}
|
||||
if updated.Type != database.VoteDown {
|
||||
t.Errorf("Expected updated vote type, got %v", updated.Type)
|
||||
}
|
||||
|
||||
err = suite.VoteRepo.Delete(vote.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete vote: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Security_SQL_Injection_Protection", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
initialCount, err := suite.UserRepo.Count()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get initial user count: %v", err)
|
||||
}
|
||||
|
||||
maliciousUser := &database.User{
|
||||
Username: "'; DROP TABLE users; --",
|
||||
Email: "malicious@example.com",
|
||||
Password: hashPassword("password"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err = suite.UserRepo.Create(maliciousUser)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user with malicious username: %v", err)
|
||||
}
|
||||
|
||||
finalCount, err := suite.UserRepo.Count()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get final user count: %v", err)
|
||||
}
|
||||
if finalCount != initialCount+1 {
|
||||
t.Errorf("Expected user count to increase by 1, got %d -> %d", initialCount, finalCount)
|
||||
}
|
||||
|
||||
retrieved, err := suite.UserRepo.GetByUsername("'; DROP TABLE users; --")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve user with malicious username: %v", err)
|
||||
}
|
||||
if retrieved.Username != "'; DROP TABLE users; --" {
|
||||
t.Errorf("Expected malicious username to be stored as-is")
|
||||
}
|
||||
|
||||
users, err := suite.UserRepo.GetAll(10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get users after SQL injection test: %v", err)
|
||||
}
|
||||
if len(users) == 0 {
|
||||
t.Error("Users table appears to have been dropped")
|
||||
}
|
||||
|
||||
var tableName string
|
||||
err = suite.DB.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").Scan(&tableName).Error
|
||||
if err != nil || tableName != "users" {
|
||||
t.Error("Users table should still exist after SQL injection attempt")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Security_Input_Validation", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
longString := string(make([]byte, 10000))
|
||||
for i := range longString {
|
||||
longString = longString[:i] + "a" + longString[i+1:]
|
||||
}
|
||||
|
||||
user := &database.User{
|
||||
Username: longString,
|
||||
Email: "long@example.com",
|
||||
Password: hashPassword("password"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err := suite.UserRepo.Create(user)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user with long username: %v", err)
|
||||
}
|
||||
|
||||
specialUser := &database.User{
|
||||
Username: "user<script>alert('xss')</script>",
|
||||
Email: "special@example.com",
|
||||
Password: hashPassword("password"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err = suite.UserRepo.Create(specialUser)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user with special characters: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Data_Consistency_Cross_Repository", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "consistency_user",
|
||||
Email: "consistency@example.com",
|
||||
Password: hashPassword("SecurePass123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err := suite.UserRepo.Create(user)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Consistency Test Post",
|
||||
URL: "https://example.com/consistency",
|
||||
Content: "Consistency test content",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
err = suite.PostRepo.Create(post)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create post: %v", err)
|
||||
}
|
||||
|
||||
voters := make([]*database.User, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
voter := &database.User{
|
||||
Username: fmt.Sprintf("voter_%d", i),
|
||||
Email: fmt.Sprintf("voter%d@example.com", i),
|
||||
Password: hashPassword("SecurePass123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err := suite.UserRepo.Create(voter)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create voter %d: %v", i, err)
|
||||
}
|
||||
voters[i] = voter
|
||||
}
|
||||
|
||||
for i, voter := range voters {
|
||||
voteType := database.VoteUp
|
||||
if i%2 == 0 {
|
||||
voteType = database.VoteDown
|
||||
}
|
||||
|
||||
vote := &database.Vote{
|
||||
UserID: &voter.ID,
|
||||
PostID: post.ID,
|
||||
Type: voteType,
|
||||
}
|
||||
err := suite.VoteRepo.Create(vote)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vote %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
votes, err := suite.VoteRepo.GetByPostID(post.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get votes: %v", err)
|
||||
}
|
||||
|
||||
var upVotes, downVotes int64
|
||||
for _, vote := range votes {
|
||||
if vote.Type == database.VoteUp {
|
||||
upVotes++
|
||||
} else if vote.Type == database.VoteDown {
|
||||
downVotes++
|
||||
}
|
||||
}
|
||||
|
||||
expectedScore := int(upVotes - downVotes)
|
||||
post.Score = expectedScore
|
||||
err = suite.PostRepo.Update(post)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update post score: %v", err)
|
||||
}
|
||||
|
||||
updatedPost, err := suite.PostRepo.GetByID(post.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve updated post: %v", err)
|
||||
}
|
||||
if updatedPost.Score != expectedScore {
|
||||
t.Errorf("Expected post score %d, got %d", expectedScore, updatedPost.Score)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Edge_Cases_Invalid_Data", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "",
|
||||
Email: "empty@example.com",
|
||||
Password: hashPassword("password"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err := suite.UserRepo.Create(user)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty username")
|
||||
}
|
||||
|
||||
user = &database.User{
|
||||
Username: "invalid_email",
|
||||
Email: "not-an-email",
|
||||
Password: hashPassword("password"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err = suite.UserRepo.Create(user)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid email format")
|
||||
}
|
||||
|
||||
user1 := &database.User{
|
||||
Username: "duplicate",
|
||||
Email: "duplicate1@example.com",
|
||||
Password: hashPassword("SecurePass123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err = suite.UserRepo.Create(user1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create first user: %v", err)
|
||||
}
|
||||
user2 := &database.User{
|
||||
Username: "duplicate",
|
||||
Email: "duplicate2@example.com",
|
||||
Password: hashPassword("password"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err = suite.UserRepo.Create(user2)
|
||||
if err == nil {
|
||||
t.Error("Expected error for duplicate username")
|
||||
}
|
||||
|
||||
user3 := &database.User{
|
||||
Username: "duplicate_email",
|
||||
Email: "duplicate1@example.com",
|
||||
Password: hashPassword("password"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err = suite.UserRepo.Create(user3)
|
||||
if err == nil {
|
||||
t.Error("Expected error for duplicate email")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Edge_Cases_Concurrent_Conflicts", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "conflict_user",
|
||||
Email: "conflict@example.com",
|
||||
Password: hashPassword("SecurePass123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err := suite.UserRepo.Create(user)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Conflict Test Post",
|
||||
URL: "https://example.com/conflict",
|
||||
Content: "Conflict test content",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
err = suite.PostRepo.Create(post)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create post: %v", err)
|
||||
}
|
||||
|
||||
vote1 := &database.Vote{
|
||||
UserID: &user.ID,
|
||||
PostID: post.ID,
|
||||
Type: database.VoteUp,
|
||||
}
|
||||
err = suite.VoteRepo.Create(vote1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create first vote: %v", err)
|
||||
}
|
||||
|
||||
vote2 := &database.Vote{
|
||||
UserID: &user.ID,
|
||||
PostID: post.ID,
|
||||
Type: database.VoteDown,
|
||||
}
|
||||
err = suite.VoteRepo.Create(vote2)
|
||||
if err == nil {
|
||||
t.Error("Expected error for duplicate vote")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Transaction_Rollback_On_Error", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "transaction_user",
|
||||
Email: "transaction@example.com",
|
||||
Password: hashPassword("SecurePass123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err := suite.UserRepo.Create(user)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
tx := suite.DB.Begin()
|
||||
defer tx.Rollback()
|
||||
|
||||
post := &database.Post{
|
||||
Title: "Transaction Test Post",
|
||||
URL: "https://example.com/transaction",
|
||||
Content: "This is a transaction test post",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
err = tx.Create(post).Error
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create post in transaction: %v", err)
|
||||
}
|
||||
|
||||
var postInTx database.Post
|
||||
err = tx.First(&postInTx, post.ID).Error
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve post in transaction: %v", err)
|
||||
}
|
||||
|
||||
tx.Rollback()
|
||||
|
||||
var postAfterRollback database.Post
|
||||
err = suite.DB.First(&postAfterRollback, post.ID).Error
|
||||
if err == nil {
|
||||
t.Error("Expected post to not exist after transaction rollback")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cascading_Delete_User_With_Posts", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "cascade_user",
|
||||
Email: "cascade@example.com",
|
||||
Password: hashPassword("SecurePass123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err := suite.UserRepo.Create(user)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
post1 := &database.Post{
|
||||
Title: "Post 1",
|
||||
URL: "https://example.com/1",
|
||||
Content: "Content 1",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
post2 := &database.Post{
|
||||
Title: "Post 2",
|
||||
URL: "https://example.com/2",
|
||||
Content: "Content 2",
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
err = suite.PostRepo.Create(post1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create post1: %v", err)
|
||||
}
|
||||
err = suite.PostRepo.Create(post2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create post2: %v", err)
|
||||
}
|
||||
|
||||
vote := &database.Vote{
|
||||
UserID: &user.ID,
|
||||
PostID: post1.ID,
|
||||
Type: database.VoteUp,
|
||||
}
|
||||
err = suite.VoteRepo.Create(vote)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create vote: %v", err)
|
||||
}
|
||||
|
||||
err = suite.UserRepo.Delete(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete user: %v", err)
|
||||
}
|
||||
|
||||
_, err = suite.UserRepo.GetByID(user.ID)
|
||||
if err == nil {
|
||||
t.Error("Expected user to be deleted")
|
||||
}
|
||||
|
||||
posts, err := suite.PostRepo.GetByUserID(user.ID, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get posts: %v", err)
|
||||
}
|
||||
if len(posts) > 0 {
|
||||
t.Errorf("Expected posts to be deleted or orphaned, found %d posts", len(posts))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Search_Functionality", func(t *testing.T) {
|
||||
suite.Reset()
|
||||
|
||||
user := &database.User{
|
||||
Username: "search_user",
|
||||
Email: "search@example.com",
|
||||
Password: hashPassword("SecurePass123!"),
|
||||
EmailVerified: true,
|
||||
}
|
||||
err := suite.UserRepo.Create(user)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
posts := []struct {
|
||||
title string
|
||||
content string
|
||||
}{
|
||||
{"Go Programming", "This post is about Go programming language"},
|
||||
{"Python Tutorial", "Learn Python programming with this tutorial"},
|
||||
{"Database Design", "Best practices for database design"},
|
||||
{"Web Development", "Modern web development techniques"},
|
||||
}
|
||||
|
||||
for i, p := range posts {
|
||||
post := &database.Post{
|
||||
Title: p.title,
|
||||
URL: fmt.Sprintf("https://example.com/post-%d", i),
|
||||
Content: p.content,
|
||||
AuthorID: &user.ID,
|
||||
}
|
||||
err := suite.PostRepo.Create(post)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create post %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
results, err := suite.PostRepo.Search("Go", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to search posts: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Error("Expected to find posts containing 'Go'")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, result := range results {
|
||||
if result.Title == "Go Programming" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Expected to find 'Go Programming' post in search results")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func hashPassword(password string) string {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to hash password: %v", err))
|
||||
}
|
||||
return string(hashed)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_Router_FullMiddlewareChain(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("SecurityHeaders_Present", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
headers := []string{
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"X-XSS-Protection",
|
||||
}
|
||||
|
||||
for _, header := range headers {
|
||||
if rec.Header().Get(header) == "" {
|
||||
t.Errorf("Expected header %s to be present", header)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CORS_Headers_Present", func(t *testing.T) {
|
||||
req := httptest.NewRequest("OPTIONS", "/api/posts", nil)
|
||||
req.Header.Set("Origin", "http://localhost:3000")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("Access-Control-Allow-Origin") == "" {
|
||||
t.Error("Expected CORS headers to be present")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Logging_Middleware_Executes", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code == 0 {
|
||||
t.Error("Expected logging middleware to execute")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RequestSizeLimit_Enforced", func(t *testing.T) {
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "size_limit_user", "size_limit@example.com")
|
||||
largeBody := strings.Repeat("a", 10*1024*1024)
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBufferString(largeBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge && rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 413 or 400 for oversized request, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DBMonitoring_Active", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
var response map[string]any
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err == nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if _, exists := data["database_stats"]; !exists {
|
||||
t.Error("Expected database_stats in health response")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Metrics_Middleware_Executes", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if _, exists := data["database"]; !exists {
|
||||
t.Error("Expected database metrics in response")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StaticFiles_Served", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/robots.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "User-agent") {
|
||||
t.Error("Expected robots.txt content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("API_Routes_Accessible", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("Health_Endpoint_Accessible", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if success, ok := response["success"].(bool); !ok || !success {
|
||||
t.Error("Expected success=true in health response")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Middleware_Order_Correct", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("X-Content-Type-Options") == "" {
|
||||
t.Error("Security headers should be applied before response")
|
||||
}
|
||||
|
||||
if rec.Code == 0 {
|
||||
t.Error("Response should have status code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Compression_Middleware_Active", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("Content-Encoding") == "" {
|
||||
t.Log("Compression may not be applied to small responses")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cache_Middleware_Active", func(t *testing.T) {
|
||||
req1 := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec1, req1)
|
||||
|
||||
req2 := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
if rec1.Code != rec2.Code {
|
||||
t.Error("Cached responses should have same status")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Auth_Middleware_Integration", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "auth_middleware_user", "auth_middleware@example.com")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("RateLimit_Middleware_Integration", func(t *testing.T) {
|
||||
rateLimitCtx := setupTestContext(t)
|
||||
rateLimitRouter := rateLimitCtx.Router
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBufferString(`{"username":"test","password":"test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
rateLimitRouter.ServeHTTP(rec, req)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", bytes.NewBufferString(`{"username":"test","password":"test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
rateLimitRouter.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
t.Log("Rate limiting is working")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"goyco/internal/database"
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/services"
|
||||
"goyco/internal/testutils"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestIntegration_Services(t *testing.T) {
|
||||
suite := testutils.NewServiceSuite(t)
|
||||
|
||||
authService, err := services.NewAuthFacadeForTest(testutils.AppTestConfig, suite.UserRepo, suite.PostRepo, suite.DeletionRepo, suite.RefreshTokenRepo, suite.EmailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create auth service: %v", err)
|
||||
}
|
||||
|
||||
voteService := services.NewVoteService(suite.VoteRepo, suite.PostRepo, suite.DB)
|
||||
emailSender := suite.EmailSender
|
||||
userRepo := suite.UserRepo
|
||||
deletionRepo := suite.DeletionRepo
|
||||
postRepo := suite.PostRepo
|
||||
titleFetcher := suite.TitleFetcher
|
||||
|
||||
t.Run("Auth_Complete_User_Lifecycle", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
registerResult, err := authService.Register("lifecycle_user", "lifecycle@example.com", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register user: %v", err)
|
||||
}
|
||||
|
||||
if registerResult.User.Username != "lifecycle_user" {
|
||||
t.Errorf("Expected username 'lifecycle_user', got '%s'", registerResult.User.Username)
|
||||
}
|
||||
|
||||
verificationToken := setupVerificationTokenForTest(t, emailSender, userRepo, "lifecycle_user")
|
||||
|
||||
_, err = authService.ConfirmEmail(verificationToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to confirm email: %v", err)
|
||||
}
|
||||
|
||||
loginResult, err := authService.Login("lifecycle_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login user: %v", err)
|
||||
}
|
||||
|
||||
if loginResult.User.Username != "lifecycle_user" {
|
||||
t.Errorf("Expected username 'lifecycle_user', got '%s'", loginResult.User.Username)
|
||||
}
|
||||
|
||||
updateResult, err := authService.UpdateUsername(loginResult.User.ID, "updated_lifecycle_user")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update username: %v", err)
|
||||
}
|
||||
|
||||
if updateResult.Username != "updated_lifecycle_user" {
|
||||
t.Errorf("Expected updated username, got '%s'", updateResult.Username)
|
||||
}
|
||||
|
||||
emailSender.Reset()
|
||||
emailResult, err := authService.UpdateEmail(loginResult.User.ID, "updated@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update email: %v", err)
|
||||
}
|
||||
|
||||
if emailResult.Email != "updated@example.com" {
|
||||
t.Errorf("Expected updated email, got '%s'", emailResult.Email)
|
||||
}
|
||||
|
||||
updatedToken := setupVerificationTokenForTest(t, emailSender, userRepo, "updated_lifecycle_user")
|
||||
|
||||
_, err = authService.ConfirmEmail(updatedToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to confirm updated email: %v", err)
|
||||
}
|
||||
|
||||
_, err = authService.UpdatePassword(loginResult.User.ID, "SecurePass123!", "NewSecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update password: %v", err)
|
||||
}
|
||||
|
||||
_, err = authService.Login("updated_lifecycle_user", "NewSecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login with new password: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Auth_Security_Validation", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
_, err := authService.Register("weak_user", "weak@example.com", "123")
|
||||
if err == nil {
|
||||
t.Error("Expected error for weak password")
|
||||
}
|
||||
|
||||
_, err = authService.Register("invalid_user", "not-an-email", "SecurePass123!")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid email")
|
||||
}
|
||||
|
||||
_, err = authService.Register("duplicate_user", "duplicate1@example.com", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register first user: %v", err)
|
||||
}
|
||||
|
||||
_, err = authService.Register("duplicate_user", "duplicate2@example.com", "SecurePass123!")
|
||||
if err == nil {
|
||||
t.Error("Expected error for duplicate username")
|
||||
}
|
||||
|
||||
_, err = authService.Register("another_user", "duplicate1@example.com", "SecurePass123!")
|
||||
if err == nil {
|
||||
t.Error("Expected error for duplicate email")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Auth_Account_Deletion_Workflow", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
registerResult, err := authService.Register("deletion_user", "deletion@example.com", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register user: %v", err)
|
||||
}
|
||||
|
||||
verificationToken := setupVerificationTokenForTest(t, emailSender, userRepo, "deletion_user")
|
||||
|
||||
_, err = authService.ConfirmEmail(verificationToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to confirm email: %v", err)
|
||||
}
|
||||
|
||||
err = authService.RequestAccountDeletion(registerResult.User.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to request account deletion: %v", err)
|
||||
}
|
||||
|
||||
deletionToken := setupDeletionTokenForTest(t, emailSender, deletionRepo, registerResult.User.ID)
|
||||
|
||||
err = authService.ConfirmAccountDeletion(deletionToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to confirm account deletion: %v", err)
|
||||
}
|
||||
|
||||
if err := authService.ConfirmAccountDeletion(deletionToken); !errors.Is(err, services.ErrInvalidDeletionToken) {
|
||||
t.Fatalf("Expected token reuse to return ErrInvalidDeletionToken, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Auth_Locked_User_Session_Invalidation", func(t *testing.T) {
|
||||
user := &database.User{
|
||||
Username: "locked_user",
|
||||
Email: "locked@example.com",
|
||||
Password: "$2a$10$abcdefghijklmnopqrstuvwxyz",
|
||||
EmailVerified: true,
|
||||
}
|
||||
if err := userRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := services.TokenClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
SessionVersion: user.SessionVersion,
|
||||
TokenType: services.TokenTypeAccess,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: testutils.AppTestConfig.JWT.Issuer,
|
||||
Audience: []string{testutils.AppTestConfig.JWT.Audience},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
|
||||
Subject: fmt.Sprint(user.ID),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(testutils.AppTestConfig.JWT.Secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
userID, err := authService.VerifyToken(tokenString)
|
||||
if err != nil {
|
||||
t.Fatalf("Token should be valid before locking: %v", err)
|
||||
}
|
||||
if userID != user.ID {
|
||||
t.Fatalf("Expected user ID %d, got %d", user.ID, userID)
|
||||
}
|
||||
|
||||
if err := userRepo.Lock(user.ID); err != nil {
|
||||
t.Fatalf("Failed to lock user: %v", err)
|
||||
}
|
||||
|
||||
_, err = authService.VerifyToken(tokenString)
|
||||
if !errors.Is(err, services.ErrAccountLocked) {
|
||||
t.Fatalf("Expected ErrAccountLocked, got %v", err)
|
||||
}
|
||||
|
||||
if err := userRepo.Unlock(user.ID); err != nil {
|
||||
t.Fatalf("Failed to unlock user: %v", err)
|
||||
}
|
||||
|
||||
userID, err = authService.VerifyToken(tokenString)
|
||||
if err != nil {
|
||||
t.Fatalf("Token should be valid after unlock: %v", err)
|
||||
}
|
||||
if userID != user.ID {
|
||||
t.Fatalf("Expected user ID %d, got %d", user.ID, userID)
|
||||
}
|
||||
|
||||
userRepo.HardDelete(user.ID)
|
||||
})
|
||||
|
||||
t.Run("Auth_Password_Change_Session_Invalidation", func(t *testing.T) {
|
||||
user := &database.User{
|
||||
Username: "password_test_user",
|
||||
Email: "password_test@example.com",
|
||||
Password: "$2a$10$abcdefghijklmnopqrstuvwxyz",
|
||||
EmailVerified: true,
|
||||
SessionVersion: 1,
|
||||
}
|
||||
if err := userRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := services.TokenClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
SessionVersion: 1,
|
||||
TokenType: services.TokenTypeAccess,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: testutils.AppTestConfig.JWT.Issuer,
|
||||
Audience: []string{testutils.AppTestConfig.JWT.Audience},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
|
||||
Subject: fmt.Sprint(user.ID),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(testutils.AppTestConfig.JWT.Secret))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
userID, err := authService.VerifyToken(tokenString)
|
||||
if err != nil {
|
||||
t.Fatalf("Token should be valid before password change: %v", err)
|
||||
}
|
||||
if userID != user.ID {
|
||||
t.Fatalf("Expected user ID %d, got %d", user.ID, userID)
|
||||
}
|
||||
|
||||
if err := authService.InvalidateAllSessions(user.ID); err != nil {
|
||||
t.Fatalf("Failed to invalidate sessions: %v", err)
|
||||
}
|
||||
|
||||
_, err = authService.VerifyToken(tokenString)
|
||||
if err == nil {
|
||||
t.Fatalf("Token should be invalid after session invalidation")
|
||||
}
|
||||
|
||||
userRepo.HardDelete(user.ID)
|
||||
})
|
||||
|
||||
t.Run("Auth_Email_Change_Verification_Template", func(t *testing.T) {
|
||||
user := &database.User{
|
||||
Username: "email_change_user",
|
||||
Email: "old@example.com",
|
||||
Password: "$2a$10$abcdefghijklmnopqrstuvwxyz",
|
||||
EmailVerified: true,
|
||||
SessionVersion: 1,
|
||||
}
|
||||
if err := userRepo.Create(user); err != nil {
|
||||
t.Fatalf("Failed to create user: %v", err)
|
||||
}
|
||||
|
||||
emailService, err := services.NewEmailService(testutils.AppTestConfig, suite.EmailSender)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create email service: %v", err)
|
||||
}
|
||||
verificationURL := "https://example.com/confirm?token=test123"
|
||||
body := emailService.GenerateEmailChangeVerificationEmailBody(user.Username, verificationURL)
|
||||
|
||||
if !strings.Contains(body, "Confirm your new email address") {
|
||||
t.Error("Email should contain 'Confirm your new email address'")
|
||||
}
|
||||
if !strings.Contains(body, "You've requested to change your email address") {
|
||||
t.Error("Email should contain email change specific message")
|
||||
}
|
||||
if !strings.Contains(body, "Confirm New Email Address") {
|
||||
t.Error("Email should contain 'Confirm New Email Address' button text")
|
||||
}
|
||||
if !strings.Contains(body, "your new email address will be active") {
|
||||
t.Error("Email should mention that new email will be active")
|
||||
}
|
||||
if !strings.Contains(body, "If you didn't request this email change") {
|
||||
t.Error("Email should contain security warning about email change")
|
||||
}
|
||||
|
||||
userRepo.HardDelete(user.ID)
|
||||
})
|
||||
|
||||
t.Run("Vote_Service_Complete_Workflow", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createTestUserWithAuth(authService, emailSender, suite.UserRepo, "vote_user", "vote@example.com")
|
||||
post := testutils.CreatePostWithRepo(t, postRepo, user.ID, "Vote Test Post", "https://example.com/vote-test")
|
||||
|
||||
voteRequest := services.VoteRequest{
|
||||
UserID: user.ID,
|
||||
PostID: post.ID,
|
||||
Type: "up",
|
||||
}
|
||||
voteResult, err := voteService.CastVote(voteRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to cast vote: %v", err)
|
||||
}
|
||||
|
||||
if voteResult.Type != database.VoteUp {
|
||||
t.Errorf("Expected vote type 'up', got '%v'", voteResult.Type)
|
||||
}
|
||||
|
||||
userVote, err := voteService.GetUserVote(user.ID, post.ID, "127.0.0.1", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user vote: %v", err)
|
||||
}
|
||||
|
||||
if userVote == nil || userVote.Type != database.VoteUp {
|
||||
t.Errorf("Expected user vote type 'up', got '%v'", userVote)
|
||||
}
|
||||
|
||||
votes, err := voteService.GetPostVotes(post.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get post votes: %v", err)
|
||||
}
|
||||
|
||||
totalVotes := len(votes)
|
||||
if totalVotes != 1 {
|
||||
t.Errorf("Expected 1 vote, got %d", totalVotes)
|
||||
}
|
||||
|
||||
voteRequest = services.VoteRequest{
|
||||
UserID: user.ID,
|
||||
PostID: post.ID,
|
||||
Type: "down",
|
||||
}
|
||||
voteResult, err = voteService.CastVote(voteRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to change vote: %v", err)
|
||||
}
|
||||
|
||||
if voteResult.Type != database.VoteDown {
|
||||
t.Errorf("Expected vote type 'down', got '%v'", voteResult.Type)
|
||||
}
|
||||
|
||||
removeRequest := services.VoteRequest{
|
||||
UserID: user.ID,
|
||||
PostID: post.ID,
|
||||
Type: database.VoteNone,
|
||||
}
|
||||
_, err = voteService.CastVote(removeRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to remove vote: %v", err)
|
||||
}
|
||||
|
||||
_, err = voteService.GetUserVote(user.ID, post.ID, "127.0.0.1", "test")
|
||||
if err == nil {
|
||||
t.Error("Expected error when getting removed vote")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Vote_Service_Concurrent_Operations", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
users := make([]*database.User, 5)
|
||||
for i := range 5 {
|
||||
users[i] = createTestUserWithAuth(authService, emailSender, suite.UserRepo, fmt.Sprintf("concurrent_user_%d", i), fmt.Sprintf("concurrent%d@example.com", i))
|
||||
}
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, postRepo, users[0].ID, "Concurrent Vote Post", "https://example.com/concurrent-vote")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, len(users))
|
||||
|
||||
for i, user := range users {
|
||||
wg.Add(1)
|
||||
go func(index int, u *database.User) {
|
||||
defer wg.Done()
|
||||
|
||||
voteType := database.VoteUp
|
||||
if index%2 == 0 {
|
||||
voteType = database.VoteDown
|
||||
}
|
||||
|
||||
voteRequest := services.VoteRequest{
|
||||
UserID: u.ID,
|
||||
PostID: post.ID,
|
||||
Type: voteType,
|
||||
}
|
||||
_, err := voteService.CastVote(voteRequest)
|
||||
if err != nil {
|
||||
errors <- fmt.Errorf("failed to cast vote for user %d: %v", index, err)
|
||||
}
|
||||
}(i, user)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
var errs []error
|
||||
for err := range errors {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
t.Fatalf("concurrent vote failures: %v", errs)
|
||||
}
|
||||
|
||||
votes, err := voteService.GetPostVotes(post.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get post votes: %v", err)
|
||||
}
|
||||
|
||||
totalVotes := len(votes)
|
||||
if totalVotes != 5 {
|
||||
t.Errorf("Expected 5 votes, got %d", totalVotes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Title_Fetcher_Functionality", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
titleFetcher.SetTitle("Mock Title")
|
||||
title, err := titleFetcher.FetchTitle(context.Background(), "https://example.com/test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch title: %v", err)
|
||||
}
|
||||
|
||||
if title != "Mock Title" {
|
||||
t.Errorf("Expected title 'Mock Title', got '%s'", title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Error_Handling_Invalid_Operations", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createTestUserWithAuth(authService, emailSender, suite.UserRepo, "error_user", "error@example.com")
|
||||
voteRequest := services.VoteRequest{
|
||||
UserID: user.ID,
|
||||
PostID: 99999,
|
||||
Type: "up",
|
||||
}
|
||||
_, err := voteService.CastVote(voteRequest)
|
||||
if err == nil {
|
||||
t.Error("Expected error when voting on non-existent post")
|
||||
}
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, postRepo, user.ID, "Error Test Post", "https://example.com/error-test")
|
||||
voteRequest = services.VoteRequest{
|
||||
UserID: 99999,
|
||||
PostID: post.ID,
|
||||
Type: "up",
|
||||
}
|
||||
_, err = voteService.CastVote(voteRequest)
|
||||
if err == nil {
|
||||
t.Error("Expected error when voting with non-existent user")
|
||||
}
|
||||
|
||||
voteRequest = services.VoteRequest{
|
||||
UserID: user.ID,
|
||||
PostID: post.ID,
|
||||
Type: "invalid",
|
||||
}
|
||||
_, err = voteService.CastVote(voteRequest)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid vote type")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Data_Consistency_Cross_Services", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createTestUserWithAuth(authService, emailSender, suite.UserRepo, "consistency_user", "consistency@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, postRepo, user.ID, "Consistency Test Post", "https://example.com/consistency")
|
||||
|
||||
voters := make([]*database.User, 3)
|
||||
for i := range 3 {
|
||||
voters[i] = createTestUserWithAuth(authService, emailSender, suite.UserRepo, fmt.Sprintf("voter_%d", i), fmt.Sprintf("voter%d@example.com", i))
|
||||
}
|
||||
|
||||
for i, voter := range voters {
|
||||
voteType := database.VoteUp
|
||||
if i%2 == 0 {
|
||||
voteType = database.VoteDown
|
||||
}
|
||||
voteRequest := services.VoteRequest{
|
||||
UserID: voter.ID,
|
||||
PostID: post.ID,
|
||||
Type: voteType,
|
||||
}
|
||||
_, err := voteService.CastVote(voteRequest)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to cast vote %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
votes, err := voteService.GetPostVotes(post.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get post votes: %v", err)
|
||||
}
|
||||
|
||||
totalVotes := len(votes)
|
||||
if totalVotes != 3 {
|
||||
t.Errorf("Expected 3 votes, got %d", totalVotes)
|
||||
}
|
||||
|
||||
for i, voter := range voters {
|
||||
userVote, err := voteService.GetUserVote(voter.ID, post.ID, "127.0.0.1", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user vote %d: %v", i, err)
|
||||
}
|
||||
|
||||
expectedType := database.VoteUp
|
||||
if i%2 == 0 {
|
||||
expectedType = database.VoteDown
|
||||
}
|
||||
|
||||
if userVote.Type != expectedType {
|
||||
t.Errorf("Expected vote type '%v' for user %d, got '%v'", expectedType, i, userVote.Type)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmailSender_Integration", func(t *testing.T) {
|
||||
sender := testutils.GetSMTPSenderFromEnv(t)
|
||||
|
||||
recipient := os.Getenv("SMTP_TEST_RECIPIENT")
|
||||
if strings.TrimSpace(recipient) == "" {
|
||||
recipient = sender.From
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Test Subject %d", time.Now().UnixNano())
|
||||
body := fmt.Sprintf("Test Body sent at %s", time.Now().Format(time.RFC3339))
|
||||
|
||||
err := sender.Send(recipient, subject, body)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmailSender_HTML_Email", func(t *testing.T) {
|
||||
sender := testutils.GetSMTPSenderFromEnv(t)
|
||||
|
||||
recipient := os.Getenv("SMTP_TEST_RECIPIENT")
|
||||
if strings.TrimSpace(recipient) == "" {
|
||||
recipient = sender.From
|
||||
}
|
||||
|
||||
htmlBody := "<html><body><h1>Test</h1><p>This is a test email.</p></body></html>"
|
||||
err := sender.Send(recipient, "HTML Test Subject", htmlBody)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmailSender_Async_Email", func(t *testing.T) {
|
||||
sender := testutils.GetSMTPSenderFromEnv(t)
|
||||
|
||||
recipient := os.Getenv("SMTP_TEST_RECIPIENT")
|
||||
if strings.TrimSpace(recipient) == "" {
|
||||
recipient = sender.From
|
||||
}
|
||||
|
||||
asyncBody := fmt.Sprintf("Async Test Body sent at %s", time.Now().Format(time.RFC3339))
|
||||
err := sender.Send(recipient, "Async Test Subject", asyncBody)
|
||||
if err != nil {
|
||||
t.Errorf("Send failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refresh_Token_Complete_Workflow", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createTestUserWithAuth(authService, emailSender, suite.UserRepo, "refresh_user", "refresh@example.com")
|
||||
|
||||
loginResult, err := authService.Login("refresh_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
if loginResult.RefreshToken == "" {
|
||||
t.Fatal("Login should return a refresh token")
|
||||
}
|
||||
|
||||
newAccessToken, err := authService.RefreshAccessToken(loginResult.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to refresh access token: %v", err)
|
||||
}
|
||||
|
||||
if newAccessToken.AccessToken == "" {
|
||||
t.Fatal("Refresh should return a new access token")
|
||||
}
|
||||
|
||||
if newAccessToken.AccessToken == loginResult.AccessToken {
|
||||
t.Error("New access token should be different from original")
|
||||
}
|
||||
|
||||
userID, err := authService.VerifyToken(newAccessToken.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("New access token should be valid: %v", err)
|
||||
}
|
||||
|
||||
if userID != user.ID {
|
||||
t.Errorf("Expected user ID %d, got %d", user.ID, userID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refresh_Token_Expiration", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
createTestUserWithAuth(authService, emailSender, suite.UserRepo, "expire_user", "expire@example.com")
|
||||
|
||||
loginResult, err := authService.Login("expire_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
refreshToken, err := suite.RefreshTokenRepo.GetByTokenHash(testutils.HashVerificationToken(loginResult.RefreshToken))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get refresh token: %v", err)
|
||||
}
|
||||
|
||||
refreshToken.ExpiresAt = time.Now().Add(-1 * time.Hour)
|
||||
if err := suite.DB.Model(refreshToken).Update("expires_at", refreshToken.ExpiresAt).Error; err != nil {
|
||||
t.Fatalf("Failed to update token expiration: %v", err)
|
||||
}
|
||||
|
||||
_, err = authService.RefreshAccessToken(loginResult.RefreshToken)
|
||||
if err == nil {
|
||||
t.Error("Expected error for expired refresh token")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refresh_Token_Revocation", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
createTestUserWithAuth(authService, emailSender, suite.UserRepo, "revoke_user", "revoke@example.com")
|
||||
|
||||
loginResult, err := authService.Login("revoke_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
err = authService.RevokeRefreshToken(loginResult.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to revoke refresh token: %v", err)
|
||||
}
|
||||
|
||||
_, err = authService.RefreshAccessToken(loginResult.RefreshToken)
|
||||
if err == nil {
|
||||
t.Error("Expected error for revoked refresh token")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refresh_Token_Multiple_Tokens", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createTestUserWithAuth(authService, emailSender, suite.UserRepo, "multi_token_user", "multi@example.com")
|
||||
|
||||
login1, err := authService.Login("multi_token_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed first login: %v", err)
|
||||
}
|
||||
|
||||
login2, err := authService.Login("multi_token_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed second login: %v", err)
|
||||
}
|
||||
|
||||
if login1.RefreshToken == login2.RefreshToken {
|
||||
t.Error("Each login should generate a unique refresh token")
|
||||
}
|
||||
|
||||
accessToken1, err := authService.RefreshAccessToken(login1.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to refresh with first token: %v", err)
|
||||
}
|
||||
|
||||
accessToken2, err := authService.RefreshAccessToken(login2.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to refresh with second token: %v", err)
|
||||
}
|
||||
|
||||
if accessToken1.AccessToken == accessToken2.AccessToken {
|
||||
t.Error("Different refresh tokens should generate different access tokens")
|
||||
}
|
||||
|
||||
userID1, err := authService.VerifyToken(accessToken1.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("First access token should be valid: %v", err)
|
||||
}
|
||||
|
||||
userID2, err := authService.VerifyToken(accessToken2.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Second access token should be valid: %v", err)
|
||||
}
|
||||
|
||||
if userID1 != user.ID || userID2 != user.ID {
|
||||
t.Error("Both tokens should belong to the same user")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refresh_Token_Revoke_All", func(t *testing.T) {
|
||||
emailSender.Reset()
|
||||
user := createTestUserWithAuth(authService, emailSender, suite.UserRepo, "revoke_all_user", "revoke_all@example.com")
|
||||
|
||||
login1, err := authService.Login("revoke_all_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed first login: %v", err)
|
||||
}
|
||||
|
||||
login2, err := authService.Login("revoke_all_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed second login: %v", err)
|
||||
}
|
||||
|
||||
err = authService.RevokeAllUserTokens(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to revoke all tokens: %v", err)
|
||||
}
|
||||
|
||||
_, err = authService.RefreshAccessToken(login1.RefreshToken)
|
||||
if err == nil {
|
||||
t.Error("Expected error for revoked refresh token")
|
||||
}
|
||||
|
||||
_, err = authService.RefreshAccessToken(login2.RefreshToken)
|
||||
if err == nil {
|
||||
t.Error("Expected error for revoked refresh token")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func createTestUserWithAuth(authService interface {
|
||||
Register(username, email, password string) (*services.RegistrationResult, error)
|
||||
ConfirmEmail(token string) (*database.User, error)
|
||||
}, emailSender interface {
|
||||
Reset()
|
||||
VerificationToken() string
|
||||
}, userRepo repositories.UserRepository, username, email string) *database.User {
|
||||
emailSender.Reset()
|
||||
|
||||
_, err := authService.Register(username, email, "SecurePass123!")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to register user: %v", err))
|
||||
}
|
||||
|
||||
verificationToken := emailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
panic("Failed to capture verification token during test setup")
|
||||
}
|
||||
|
||||
hashedToken := testutils.HashVerificationToken(verificationToken)
|
||||
|
||||
user, err := userRepo.GetByUsername(username)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to get user: %v", err))
|
||||
}
|
||||
user.EmailVerificationToken = hashedToken
|
||||
if err := userRepo.Update(user); err != nil {
|
||||
panic(fmt.Sprintf("Failed to update user with hashed token: %v", err))
|
||||
}
|
||||
|
||||
confirmResult, err := authService.ConfirmEmail(verificationToken)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to confirm email: %v", err))
|
||||
}
|
||||
|
||||
return confirmResult
|
||||
}
|
||||
|
||||
func setupVerificationTokenForTest(t *testing.T, emailSender *testutils.MockEmailSender, userRepo repositories.UserRepository, username string) string {
|
||||
t.Helper()
|
||||
|
||||
verificationToken := emailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
t.Fatal("Expected verification token to be generated")
|
||||
}
|
||||
|
||||
hashedToken := testutils.HashVerificationToken(verificationToken)
|
||||
|
||||
user, err := userRepo.GetByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
user.EmailVerificationToken = hashedToken
|
||||
if err := userRepo.Update(user); err != nil {
|
||||
t.Fatalf("Failed to update user with hashed token: %v", err)
|
||||
}
|
||||
|
||||
return verificationToken
|
||||
}
|
||||
|
||||
func setupDeletionTokenForTest(t *testing.T, emailSender *testutils.MockEmailSender, deletionRepo repositories.AccountDeletionRepository, userID uint) string {
|
||||
t.Helper()
|
||||
|
||||
deletionToken := emailSender.DeletionToken()
|
||||
if deletionToken == "" {
|
||||
t.Fatal("Expected deletion token to be generated")
|
||||
}
|
||||
|
||||
hashedToken := testutils.HashVerificationToken(deletionToken)
|
||||
|
||||
if err := deletionRepo.DeleteByUserID(userID); err != nil {
|
||||
t.Fatalf("Cannot delete user %d", userID)
|
||||
}
|
||||
|
||||
req := &database.AccountDeletionRequest{
|
||||
UserID: userID,
|
||||
TokenHash: hashedToken,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
|
||||
if err := deletionRepo.Create(req); err != nil {
|
||||
t.Fatalf("Failed to create account deletion request: %v", err)
|
||||
}
|
||||
|
||||
return deletionToken
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/middleware"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestIntegration_SessionManagement(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("Session_Invalidation_On_Password_Change", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "session_pass_user", "session_pass@example.com")
|
||||
|
||||
req1 := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
req1.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req1 = testutils.WithUserContext(req1, middleware.UserIDKey, user.User.ID)
|
||||
rec1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec1, req1)
|
||||
|
||||
assertStatus(t, rec1, http.StatusOK)
|
||||
|
||||
reqBody := map[string]string{
|
||||
"current_password": "SecurePass123!",
|
||||
"new_password": "NewSecurePass123!",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req2 := httptest.NewRequest("PUT", "/api/auth/password", bytes.NewBuffer(body))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req2 = testutils.WithUserContext(req2, middleware.UserIDKey, user.User.ID)
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
assertStatus(t, rec2, http.StatusOK)
|
||||
|
||||
req3 := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
req3.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req3 = testutils.WithUserContext(req3, middleware.UserIDKey, user.User.ID)
|
||||
rec3 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec3, req3)
|
||||
|
||||
assertErrorResponse(t, rec3, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Session_Invalidation_On_Account_Lock", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "session_lock_user", "session_lock@example.com")
|
||||
|
||||
req1 := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
req1.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req1 = testutils.WithUserContext(req1, middleware.UserIDKey, user.User.ID)
|
||||
rec1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec1, req1)
|
||||
|
||||
assertStatus(t, rec1, http.StatusOK)
|
||||
|
||||
if err := ctx.Suite.UserRepo.Lock(user.User.ID); err != nil {
|
||||
t.Fatalf("Failed to lock user: %v", err)
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req2 = testutils.WithUserContext(req2, middleware.UserIDKey, user.User.ID)
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
assertErrorResponse(t, rec2, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Refresh_Token_Revocation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "refresh_revoke_user", "refresh_revoke@example.com")
|
||||
|
||||
loginResult, err := ctx.AuthService.Login("refresh_revoke_user", "SecurePass123!")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
if loginResult.RefreshToken == "" {
|
||||
t.Fatal("Expected refresh token")
|
||||
}
|
||||
|
||||
reqBody := map[string]string{
|
||||
"refresh_token": loginResult.RefreshToken,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/auth/refresh", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assertStatus(t, rec, http.StatusOK)
|
||||
|
||||
if err := ctx.AuthService.RevokeRefreshToken(loginResult.RefreshToken); err != nil {
|
||||
t.Fatalf("Failed to revoke token: %v", err)
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest("POST", "/api/auth/refresh", bytes.NewBuffer(body))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
assertErrorResponse(t, rec2, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("Multiple_Sessions_Independent", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user1 := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "multi_session_user1", "multi_session1@example.com")
|
||||
user2 := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "multi_session_user2", "multi_session2@example.com")
|
||||
|
||||
req1 := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
req1.Header.Set("Authorization", "Bearer "+user1.Token)
|
||||
req1 = testutils.WithUserContext(req1, middleware.UserIDKey, user1.User.ID)
|
||||
rec1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec1, req1)
|
||||
|
||||
req2 := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+user2.Token)
|
||||
req2 = testutils.WithUserContext(req2, middleware.UserIDKey, user2.User.ID)
|
||||
rec2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec2, req2)
|
||||
|
||||
assertStatus(t, rec1, http.StatusOK)
|
||||
assertStatus(t, rec2, http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_AccountDeletion(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("Account_Deletion_Complete_Flow", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "del_flow_user", "del_flow@example.com")
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Test Post", "https://example.com")
|
||||
|
||||
reqBody := map[string]string{}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("DELETE", "/api/auth/account", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := response["message"]; !ok {
|
||||
t.Error("Expected message field in response")
|
||||
}
|
||||
|
||||
deletionToken := ctx.Suite.EmailSender.DeletionToken()
|
||||
if deletionToken == "" {
|
||||
t.Fatal("Expected deletion token")
|
||||
}
|
||||
|
||||
confirmBody := map[string]any{
|
||||
"token": deletionToken,
|
||||
}
|
||||
confirmBodyBytes, _ := json.Marshal(confirmBody)
|
||||
confirmReq := httptest.NewRequest("POST", "/api/auth/account/confirm", bytes.NewBuffer(confirmBodyBytes))
|
||||
confirmReq.Header.Set("Content-Type", "application/json")
|
||||
confirmRec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(confirmRec, confirmReq)
|
||||
|
||||
confirmResponse := assertJSONResponse(t, confirmRec, http.StatusOK)
|
||||
if confirmResponse == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := confirmResponse["message"]; !ok {
|
||||
t.Error("Expected message field in confirmation response")
|
||||
}
|
||||
if data, ok := confirmResponse["data"].(map[string]any); ok {
|
||||
if postsDeleted, ok := data["posts_deleted"].(bool); ok && postsDeleted {
|
||||
t.Error("Expected posts_deleted to be false when not specified")
|
||||
}
|
||||
}
|
||||
|
||||
_, err := ctx.Suite.UserRepo.GetByID(user.User.ID)
|
||||
if err == nil {
|
||||
t.Error("Expected user to be deleted")
|
||||
}
|
||||
|
||||
retrievedPost, err := ctx.Suite.PostRepo.GetByID(post.ID)
|
||||
if err != nil {
|
||||
t.Fatal("Expected post to still exist after soft delete")
|
||||
}
|
||||
if retrievedPost.AuthorID != nil {
|
||||
t.Error("Expected post author_id to be null after user deletion")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Account_Deletion_With_Posts", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "del_posts_user", "del_posts@example.com")
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Deletion Post", "https://example.com/deletion")
|
||||
|
||||
reqBody := map[string]string{}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("DELETE", "/api/auth/account", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := response["message"]; !ok {
|
||||
t.Error("Expected message field in response")
|
||||
}
|
||||
|
||||
deletionToken := ctx.Suite.EmailSender.DeletionToken()
|
||||
if deletionToken == "" {
|
||||
t.Fatal("Expected deletion token")
|
||||
}
|
||||
|
||||
confirmBody := map[string]any{
|
||||
"token": deletionToken,
|
||||
"delete_posts": true,
|
||||
}
|
||||
confirmBodyBytes, _ := json.Marshal(confirmBody)
|
||||
confirmReq := httptest.NewRequest("POST", "/api/auth/account/confirm", bytes.NewBuffer(confirmBodyBytes))
|
||||
confirmReq.Header.Set("Content-Type", "application/json")
|
||||
confirmRec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(confirmRec, confirmReq)
|
||||
|
||||
confirmResponse := assertJSONResponse(t, confirmRec, http.StatusOK)
|
||||
if confirmResponse == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := confirmResponse["message"]; !ok {
|
||||
t.Error("Expected message field in confirmation response")
|
||||
}
|
||||
if data, ok := confirmResponse["data"].(map[string]any); ok {
|
||||
if postsDeleted, ok := data["posts_deleted"].(bool); !ok || !postsDeleted {
|
||||
t.Error("Expected posts_deleted to be true")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected data field with posts_deleted in confirmation response")
|
||||
}
|
||||
|
||||
_, err := ctx.Suite.UserRepo.GetByID(user.User.ID)
|
||||
if err == nil {
|
||||
t.Error("Expected user to be deleted")
|
||||
}
|
||||
|
||||
_, err = ctx.Suite.PostRepo.GetByID(post.ID)
|
||||
if err == nil {
|
||||
t.Error("Expected post to be deleted with user")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_MetricsCollection(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("Metrics_Endpoint_Returns_Data", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
response := assertJSONResponse(t, rec, http.StatusOK)
|
||||
if response != nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if _, exists := data["database"]; !exists {
|
||||
t.Error("Expected database metrics")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Metrics_Includes_DB_Stats", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "metrics_user", "metrics@example.com")
|
||||
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
var response map[string]any
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err == nil {
|
||||
if data, ok := response["data"].(map[string]any); ok {
|
||||
if dbData, exists := data["database"].(map[string]any); exists {
|
||||
if _, hasQueries := dbData["total_queries"]; !hasQueries {
|
||||
t.Log("Database query metrics may not be available")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_ConcurrentRequests(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
router := ctx.Router
|
||||
|
||||
t.Run("Concurrent_Post_Creation", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "concurrent_user", "concurrent@example.com")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
|
||||
postBody := map[string]string{
|
||||
"title": fmt.Sprintf("Concurrent Post %d", index),
|
||||
"url": fmt.Sprintf("https://example.com/concurrent-%d", index),
|
||||
"content": "Concurrent test content",
|
||||
}
|
||||
body, _ := json.Marshal(postBody)
|
||||
req := httptest.NewRequest("POST", "/api/posts", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
errors <- fmt.Errorf("Post %d failed with status %d", index, rec.Code)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
var errs []error
|
||||
for err := range errors {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
t.Errorf("Concurrent post creation failed: %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Concurrent_Vote_Operations", func(t *testing.T) {
|
||||
ctx.Suite.EmailSender.Reset()
|
||||
user := createAuthenticatedUser(t, ctx.AuthService, ctx.Suite.UserRepo, "concurrent_vote_user", "concurrent_vote@example.com")
|
||||
|
||||
post := testutils.CreatePostWithRepo(t, ctx.Suite.PostRepo, user.User.ID, "Concurrent Vote Post", "https://example.com/concurrent-vote")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 5)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
voteBody := map[string]string{
|
||||
"type": "up",
|
||||
}
|
||||
body, _ := json.Marshal(voteBody)
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/api/posts/%d/vote", post.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+user.Token)
|
||||
req = testutils.WithUserContext(req, middleware.UserIDKey, user.User.ID)
|
||||
req = testutils.WithURLParams(req, map[string]string{"id": fmt.Sprintf("%d", post.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
errors <- fmt.Errorf("Vote failed with status %d", rec.Code)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
var errs []error
|
||||
for err := range errors {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
t.Logf("Some concurrent votes may have failed (expected): %v", errs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Concurrent_Read_Operations", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 20)
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
errors <- fmt.Errorf("Read failed with status %d", rec.Code)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
var errs []error
|
||||
for err := range errors {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
t.Errorf("Concurrent reads failed: %v", errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserIDKey contextKey = "user_id"
|
||||
|
||||
type TokenVerifier interface {
|
||||
VerifyToken(token string) (uint, error)
|
||||
}
|
||||
|
||||
func sendJSONError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": false,
|
||||
"error": message,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func NewAuth(verifier TokenVerifier) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if authHeader == "" {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
sendJSONError(w, "Authorization header required", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Error(w, "Authorization header required", http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
sendJSONError(w, "Invalid authorization header", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||
if tokenString == "" {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
sendJSONError(w, "Invalid authorization token", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Error(w, "Invalid authorization token", http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := verifier.VerifyToken(tokenString)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
sendJSONError(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserIDFromContext(ctx context.Context) uint {
|
||||
if userID, ok := ctx.Value(UserIDKey).(uint); ok {
|
||||
return userID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubVerifier struct {
|
||||
userID uint
|
||||
err error
|
||||
token string
|
||||
}
|
||||
|
||||
func (s *stubVerifier) VerifyToken(token string) (uint, error) {
|
||||
s.token = token
|
||||
if s.err != nil {
|
||||
return 0, s.err
|
||||
}
|
||||
return s.userID, nil
|
||||
}
|
||||
|
||||
func TestNewAuthWithoutAuthorization(t *testing.T) {
|
||||
verifier := &stubVerifier{userID: 42}
|
||||
called := false
|
||||
|
||||
middleware := NewAuth(verifier)
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
if id := GetUserIDFromContext(r.Context()); id != 0 {
|
||||
t.Fatalf("unexpected user id %d", id)
|
||||
}
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if called {
|
||||
t.Fatal("expected next handler NOT to be called when no authorization header")
|
||||
}
|
||||
|
||||
if recorder.Result().StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status 401, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthValidToken(t *testing.T) {
|
||||
verifier := &stubVerifier{userID: 99}
|
||||
middleware := NewAuth(verifier)
|
||||
|
||||
handlerCalled := false
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
if id := GetUserIDFromContext(r.Context()); id != 99 {
|
||||
t.Fatalf("expected user id 99, got %d", id)
|
||||
}
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/secure", nil)
|
||||
request.Header.Set("Authorization", "Bearer token-123")
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if !handlerCalled {
|
||||
t.Fatal("expected handler to be called for valid token")
|
||||
}
|
||||
|
||||
if verifier.token != "token-123" {
|
||||
t.Fatalf("expected verifier to receive token-123, got %q", verifier.token)
|
||||
}
|
||||
|
||||
if recorder.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthInvalidHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
status int
|
||||
}{
|
||||
{name: "MissingBearer", header: "Token value", status: http.StatusUnauthorized},
|
||||
{name: "EmptyToken", header: "Bearer ", status: http.StatusUnauthorized},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
verifier := &stubVerifier{userID: 1}
|
||||
middleware := NewAuth(verifier)
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("handler should not be called")
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.Header.Set("Authorization", tc.header)
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Result().StatusCode != tc.status {
|
||||
t.Fatalf("expected status %d, got %d", tc.status, recorder.Result().StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthVerifierError(t *testing.T) {
|
||||
verifier := &stubVerifier{err: http.ErrNoCookie}
|
||||
middleware := NewAuth(verifier)
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("handler should not be called when verifier fails")
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.Header.Set("Authorization", "Bearer token-xyz")
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Result().StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 when verifier fails, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserIDFromContext(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), UserIDKey, uint(55))
|
||||
|
||||
if id := GetUserIDFromContext(ctx); id != 55 {
|
||||
t.Fatalf("expected id 55, got %d", id)
|
||||
}
|
||||
|
||||
if id := GetUserIDFromContext(context.Background()); id != 0 {
|
||||
t.Fatalf("expected zero when id missing, got %d", id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CacheEntry struct {
|
||||
Data []byte `json:"data"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
TTL time.Duration `json:"ttl"`
|
||||
}
|
||||
|
||||
type Cache interface {
|
||||
Get(key string) (*CacheEntry, error)
|
||||
Set(key string, entry *CacheEntry) error
|
||||
Delete(key string) error
|
||||
Clear() error
|
||||
}
|
||||
|
||||
type InMemoryCache struct {
|
||||
mu sync.RWMutex
|
||||
data map[string]*CacheEntry
|
||||
}
|
||||
|
||||
func NewInMemoryCache() *InMemoryCache {
|
||||
return &InMemoryCache{
|
||||
data: make(map[string]*CacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *InMemoryCache) Get(key string) (*CacheEntry, error) {
|
||||
cache.mu.RLock()
|
||||
entry, exists := cache.data[key]
|
||||
cache.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("key not found")
|
||||
}
|
||||
|
||||
if time.Since(entry.Timestamp) > entry.TTL {
|
||||
cache.mu.Lock()
|
||||
delete(cache.data, key)
|
||||
cache.mu.Unlock()
|
||||
return nil, fmt.Errorf("entry expired")
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (cache *InMemoryCache) Set(key string, entry *CacheEntry) error {
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
cache.data[key] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cache *InMemoryCache) Delete(key string) error {
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
delete(cache.data, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cache *InMemoryCache) Clear() error {
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
cache.data = make(map[string]*CacheEntry)
|
||||
return nil
|
||||
}
|
||||
|
||||
type CacheConfig struct {
|
||||
TTL time.Duration
|
||||
MaxSize int
|
||||
CacheablePaths []string
|
||||
CacheableMethods []string
|
||||
}
|
||||
|
||||
func DefaultCacheConfig() *CacheConfig {
|
||||
return &CacheConfig{
|
||||
TTL: 5 * time.Minute,
|
||||
MaxSize: 1000,
|
||||
CacheablePaths: []string{},
|
||||
CacheableMethods: []string{"GET"},
|
||||
}
|
||||
}
|
||||
|
||||
func CacheMiddleware(cache Cache, config *CacheConfig) func(http.Handler) http.Handler {
|
||||
if config == nil {
|
||||
config = DefaultCacheConfig()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !isCacheablePath(r.URL.Path, config.CacheablePaths) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := generateCacheKey(r)
|
||||
|
||||
if entry, err := cache.Get(cacheKey); err == nil {
|
||||
for key, values := range entry.Headers {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
w.Header().Set("X-Cache", "HIT")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(entry.Data)
|
||||
return
|
||||
}
|
||||
|
||||
capturer := &responseCapturer{
|
||||
ResponseWriter: w,
|
||||
body: &bytes.Buffer{},
|
||||
headers: make(http.Header),
|
||||
}
|
||||
|
||||
next.ServeHTTP(capturer, r)
|
||||
|
||||
if capturer.statusCode == http.StatusOK {
|
||||
entry := &CacheEntry{
|
||||
Data: capturer.body.Bytes(),
|
||||
Headers: capturer.headers,
|
||||
Timestamp: time.Now(),
|
||||
TTL: config.TTL,
|
||||
}
|
||||
|
||||
go func() {
|
||||
cache.Set(cacheKey, entry)
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type responseCapturer struct {
|
||||
http.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
headers http.Header
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rc *responseCapturer) WriteHeader(code int) {
|
||||
rc.statusCode = code
|
||||
rc.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rc *responseCapturer) Write(b []byte) (int, error) {
|
||||
rc.body.Write(b)
|
||||
return rc.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rc *responseCapturer) Header() http.Header {
|
||||
return rc.headers
|
||||
}
|
||||
|
||||
func isCacheablePath(path string, cacheablePaths []string) bool {
|
||||
for _, cacheablePath := range cacheablePaths {
|
||||
if strings.HasPrefix(path, cacheablePath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func generateCacheKey(r *http.Request) string {
|
||||
key := fmt.Sprintf("%s:%s", r.Method, r.URL.Path)
|
||||
if r.URL.RawQuery != "" {
|
||||
key += "?" + r.URL.RawQuery
|
||||
}
|
||||
|
||||
if userID := GetUserIDFromContext(r.Context()); userID != 0 {
|
||||
key += fmt.Sprintf(":user:%d", userID)
|
||||
}
|
||||
|
||||
hash := md5.Sum([]byte(key))
|
||||
return fmt.Sprintf("cache:%x", hash)
|
||||
}
|
||||
|
||||
func CacheInvalidationMiddleware(cache Cache) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" {
|
||||
go func() {
|
||||
cache.Clear()
|
||||
}()
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInMemoryCache(t *testing.T) {
|
||||
cache := NewInMemoryCache()
|
||||
|
||||
t.Run("Set and Get", func(t *testing.T) {
|
||||
entry := &CacheEntry{
|
||||
Data: []byte("test data"),
|
||||
Headers: make(http.Header),
|
||||
Timestamp: time.Now(),
|
||||
TTL: 5 * time.Minute,
|
||||
}
|
||||
|
||||
err := cache.Set("test-key", entry)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set cache entry: %v", err)
|
||||
}
|
||||
|
||||
retrieved, err := cache.Get("test-key")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get cache entry: %v", err)
|
||||
}
|
||||
|
||||
if string(retrieved.Data) != "test data" {
|
||||
t.Errorf("Expected 'test data', got '%s'", string(retrieved.Data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get non-existent key", func(t *testing.T) {
|
||||
_, err := cache.Get("non-existent")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
entry := &CacheEntry{
|
||||
Data: []byte("delete test"),
|
||||
Headers: make(http.Header),
|
||||
Timestamp: time.Now(),
|
||||
TTL: 5 * time.Minute,
|
||||
}
|
||||
|
||||
cache.Set("delete-key", entry)
|
||||
err := cache.Delete("delete-key")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete cache entry: %v", err)
|
||||
}
|
||||
|
||||
_, err = cache.Get("delete-key")
|
||||
if err == nil {
|
||||
t.Error("Expected error after deletion")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Clear", func(t *testing.T) {
|
||||
entry := &CacheEntry{
|
||||
Data: []byte("clear test"),
|
||||
Headers: make(http.Header),
|
||||
Timestamp: time.Now(),
|
||||
TTL: 5 * time.Minute,
|
||||
}
|
||||
|
||||
cache.Set("clear-key", entry)
|
||||
err := cache.Clear()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to clear cache: %v", err)
|
||||
}
|
||||
|
||||
_, err = cache.Get("clear-key")
|
||||
if err == nil {
|
||||
t.Error("Expected error after clear")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Expired entry", func(t *testing.T) {
|
||||
entry := &CacheEntry{
|
||||
Data: []byte("expired data"),
|
||||
Headers: make(http.Header),
|
||||
Timestamp: time.Now().Add(-10 * time.Minute),
|
||||
TTL: 5 * time.Minute,
|
||||
}
|
||||
|
||||
cache.Set("expired-key", entry)
|
||||
_, err := cache.Get("expired-key")
|
||||
if err == nil {
|
||||
t.Error("Expected error for expired entry")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCacheMiddleware(t *testing.T) {
|
||||
cache := NewInMemoryCache()
|
||||
config := &CacheConfig{
|
||||
TTL: 5 * time.Minute,
|
||||
MaxSize: 1000,
|
||||
}
|
||||
middleware := CacheMiddleware(cache, config)
|
||||
|
||||
t.Run("Cache miss", func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Body.String() != "test response" {
|
||||
t.Errorf("Expected 'test response', got '%s'", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Cache hit", func(t *testing.T) {
|
||||
testCache := NewInMemoryCache()
|
||||
testConfig := &CacheConfig{
|
||||
TTL: 5 * time.Minute,
|
||||
MaxSize: 1000,
|
||||
CacheablePaths: []string{"/api/posts"},
|
||||
}
|
||||
testMiddleware := CacheMiddleware(testCache, testConfig)
|
||||
|
||||
request := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
callCount := 0
|
||||
handler := testMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("cached response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected handler to be called once, got %d", callCount)
|
||||
}
|
||||
|
||||
cacheKey := generateCacheKey(request)
|
||||
entry := &CacheEntry{
|
||||
Data: []byte("cached response"),
|
||||
Headers: make(http.Header),
|
||||
Timestamp: time.Now(),
|
||||
TTL: 5 * time.Minute,
|
||||
}
|
||||
testCache.Set(cacheKey, entry)
|
||||
|
||||
request2 := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
recorder2 := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder2, request2)
|
||||
|
||||
if recorder2.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder2.Code)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected handler to be called once total, got %d", callCount)
|
||||
}
|
||||
if recorder2.Body.String() != "cached response" {
|
||||
t.Errorf("Expected 'cached response', got '%s'", recorder2.Body.String())
|
||||
}
|
||||
if recorder2.Header().Get("X-Cache") != "HIT" {
|
||||
t.Error("Expected X-Cache header to be HIT")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST request not cached", func(t *testing.T) {
|
||||
request := httptest.NewRequest("POST", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
callCount := 0
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("post response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected handler to be called once, got %d", callCount)
|
||||
}
|
||||
|
||||
recorder2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder2, request)
|
||||
if callCount != 2 {
|
||||
t.Errorf("Expected handler to be called twice, got %d", callCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Personalized endpoints not cached by default", func(t *testing.T) {
|
||||
|
||||
testCache := NewInMemoryCache()
|
||||
testConfig := DefaultCacheConfig()
|
||||
testMiddleware := CacheMiddleware(testCache, testConfig)
|
||||
|
||||
personalizedPaths := []string{
|
||||
"/api/posts",
|
||||
"/api/posts/search",
|
||||
}
|
||||
|
||||
for _, path := range personalizedPaths {
|
||||
request := httptest.NewRequest("GET", path, nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
callCount := 0
|
||||
handler := testMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected handler to be called once for %s, got %d", path, callCount)
|
||||
}
|
||||
if recorder.Header().Get("X-Cache") == "HIT" {
|
||||
t.Errorf("Expected %s not to be cached, but got cache HIT", path)
|
||||
}
|
||||
|
||||
recorder2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder2, request)
|
||||
if callCount != 2 {
|
||||
t.Errorf("Expected handler to be called twice for %s (not cached), got %d", path, callCount)
|
||||
}
|
||||
if recorder2.Header().Get("X-Cache") == "HIT" {
|
||||
t.Errorf("Expected %s not to be cached on second request, but got cache HIT", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCacheKeyGeneration(t *testing.T) {
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
query string
|
||||
expected string
|
||||
}{
|
||||
{"GET", "/test", "", "cache:e2b43a77e8b6707afcc1571382ca7c73"},
|
||||
{"GET", "/test", "param=value", "cache:067b4b550d6cee93dfb106d6912ef91b"},
|
||||
{"POST", "/test", "", "cache:fb3126bb69b4d21769b5fa4d78318b0e"},
|
||||
{"PUT", "/users/123", "", "cache:40b0b7a2306bfd4998d6219c1ef29783"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+tt.path+tt.query, func(t *testing.T) {
|
||||
url := tt.path
|
||||
if tt.query != "" {
|
||||
url += "?" + tt.query
|
||||
}
|
||||
request := httptest.NewRequest(tt.method, url, nil)
|
||||
key := generateCacheKey(request)
|
||||
if key != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInMemoryCacheConcurrent(t *testing.T) {
|
||||
cache := NewInMemoryCache()
|
||||
numGoroutines := 100
|
||||
numOps := 100
|
||||
|
||||
t.Run("Concurrent writes", func(t *testing.T) {
|
||||
done := make(chan bool, numGoroutines)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(id int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Goroutine %d panicked: %v", id, r)
|
||||
}
|
||||
}()
|
||||
for j := 0; j < numOps; j++ {
|
||||
entry := &CacheEntry{
|
||||
Data: []byte(fmt.Sprintf("data-%d-%d", id, j)),
|
||||
Headers: make(http.Header),
|
||||
Timestamp: time.Now(),
|
||||
TTL: 5 * time.Minute,
|
||||
}
|
||||
key := fmt.Sprintf("key-%d-%d", id, j)
|
||||
if err := cache.Set(key, entry); err != nil {
|
||||
t.Errorf("Failed to set cache entry: %v", err)
|
||||
}
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
<-done
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Concurrent reads and writes", func(t *testing.T) {
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
entry := &CacheEntry{
|
||||
Data: []byte(fmt.Sprintf("data-%d", i)),
|
||||
Headers: make(http.Header),
|
||||
Timestamp: time.Now(),
|
||||
TTL: 5 * time.Minute,
|
||||
}
|
||||
cache.Set(fmt.Sprintf("key-%d", i), entry)
|
||||
}
|
||||
|
||||
done := make(chan bool, numGoroutines*2)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(id int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Writer goroutine %d panicked: %v", id, r)
|
||||
}
|
||||
}()
|
||||
for j := 0; j < numOps; j++ {
|
||||
entry := &CacheEntry{
|
||||
Data: []byte(fmt.Sprintf("write-%d-%d", id, j)),
|
||||
Headers: make(http.Header),
|
||||
Timestamp: time.Now(),
|
||||
TTL: 5 * time.Minute,
|
||||
}
|
||||
key := fmt.Sprintf("write-key-%d-%d", id, j)
|
||||
cache.Set(key, entry)
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(id int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Reader goroutine %d panicked: %v", id, r)
|
||||
}
|
||||
}()
|
||||
for j := 0; j < numOps; j++ {
|
||||
key := fmt.Sprintf("key-%d", j%10)
|
||||
cache.Get(key)
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < numGoroutines*2; i++ {
|
||||
<-done
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Concurrent deletes", func(t *testing.T) {
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
entry := &CacheEntry{
|
||||
Data: []byte(fmt.Sprintf("data-%d", i)),
|
||||
Headers: make(http.Header),
|
||||
Timestamp: time.Now(),
|
||||
TTL: 5 * time.Minute,
|
||||
}
|
||||
cache.Set(fmt.Sprintf("del-key-%d", i), entry)
|
||||
}
|
||||
|
||||
done := make(chan bool, numGoroutines)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(id int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("Delete goroutine %d panicked: %v", id, r)
|
||||
}
|
||||
}()
|
||||
cache.Delete(fmt.Sprintf("del-key-%d", id))
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
<-done
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCacheMiddlewareTTLExpiration(t *testing.T) {
|
||||
|
||||
testCache := NewInMemoryCache()
|
||||
testConfig := &CacheConfig{
|
||||
TTL: 100 * time.Millisecond,
|
||||
MaxSize: 1000,
|
||||
CacheablePaths: []string{"/test"},
|
||||
}
|
||||
testMiddleware := CacheMiddleware(testCache, testConfig)
|
||||
|
||||
callCount := 0
|
||||
handler := testMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("response"))
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected handler to be called once, got %d", callCount)
|
||||
}
|
||||
if recorder.Header().Get("X-Cache") != "" {
|
||||
t.Error("First request should not have X-Cache header")
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
recorder2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder2, request)
|
||||
|
||||
if recorder2.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder2.Code)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected handler to still be called once (cached), got %d", callCount)
|
||||
}
|
||||
if recorder2.Header().Get("X-Cache") != "HIT" {
|
||||
t.Error("Second request should have X-Cache: HIT header")
|
||||
}
|
||||
if recorder2.Body.String() != "response" {
|
||||
t.Errorf("Expected 'response', got '%s'", recorder2.Body.String())
|
||||
}
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
recorder3 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder3, request)
|
||||
|
||||
if recorder3.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder3.Code)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Errorf("Expected handler to be called twice (after expiry), got %d", callCount)
|
||||
}
|
||||
if recorder3.Header().Get("X-Cache") != "" {
|
||||
t.Error("Request after expiry should not have X-Cache header")
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
recorder4 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder4, request)
|
||||
|
||||
if recorder4.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder4.Code)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Errorf("Expected handler to still be called twice (cached again), got %d", callCount)
|
||||
}
|
||||
if recorder4.Header().Get("X-Cache") != "HIT" {
|
||||
t.Error("Fourth request should have X-Cache: HIT header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheMiddlewareRequestResponseSerialization(t *testing.T) {
|
||||
|
||||
testCache := NewInMemoryCache()
|
||||
testConfig := &CacheConfig{
|
||||
TTL: 5 * time.Minute,
|
||||
MaxSize: 1000,
|
||||
CacheablePaths: []string{"/api/data"},
|
||||
}
|
||||
testMiddleware := CacheMiddleware(testCache, testConfig)
|
||||
|
||||
callCount := 0
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Custom-Header", "test-value")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
handler := testMiddleware(testHandler)
|
||||
|
||||
request := httptest.NewRequest("GET", "/api/data?param=value", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected handler to be called once, got %d", callCount)
|
||||
}
|
||||
if recorder.Body.String() != `{"status":"ok"}` {
|
||||
t.Errorf("Expected JSON response, got %s", recorder.Body.String())
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
request2 := httptest.NewRequest("GET", "/api/data?param=value", nil)
|
||||
recorder2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder2, request2)
|
||||
|
||||
if recorder2.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder2.Code)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("Expected handler to still be called once (cached), got %d", callCount)
|
||||
}
|
||||
if recorder2.Header().Get("X-Cache") != "HIT" {
|
||||
t.Error("Expected X-Cache: HIT header")
|
||||
}
|
||||
|
||||
if recorder2.Header().Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Expected Content-Type header from cache, got %q", recorder2.Header().Get("Content-Type"))
|
||||
}
|
||||
if recorder2.Header().Get("X-Custom-Header") != "test-value" {
|
||||
t.Errorf("Expected X-Custom-Header from cache, got %q", recorder2.Header().Get("X-Custom-Header"))
|
||||
}
|
||||
if recorder2.Body.String() != `{"status":"ok"}` {
|
||||
t.Errorf("Expected cached JSON response, got %s", recorder2.Body.String())
|
||||
}
|
||||
|
||||
request3 := httptest.NewRequest("GET", "/api/data?param=different", nil)
|
||||
recorder3 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder3, request3)
|
||||
|
||||
if recorder3.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder3.Code)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Errorf("Expected handler to be called twice (different query params), got %d", callCount)
|
||||
}
|
||||
if recorder3.Header().Get("X-Cache") != "" {
|
||||
t.Error("Request with different params should not have X-Cache header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheInvalidationMiddleware(t *testing.T) {
|
||||
cache := NewInMemoryCache()
|
||||
|
||||
entries := []struct {
|
||||
key string
|
||||
entry *CacheEntry
|
||||
}{
|
||||
{"cache:abc123", &CacheEntry{Data: []byte("data1"), Headers: make(http.Header), Timestamp: time.Now(), TTL: 5 * time.Minute}},
|
||||
{"cache:def456", &CacheEntry{Data: []byte("data2"), Headers: make(http.Header), Timestamp: time.Now(), TTL: 5 * time.Minute}},
|
||||
{"cache:ghi789", &CacheEntry{Data: []byte("data3"), Headers: make(http.Header), Timestamp: time.Now(), TTL: 5 * time.Minute}},
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if err := cache.Set(e.key, e.entry); err != nil {
|
||||
t.Fatalf("Failed to set cache entry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if _, err := cache.Get(e.key); err != nil {
|
||||
t.Fatalf("Expected entry %s to exist, got error: %v", e.key, err)
|
||||
}
|
||||
}
|
||||
|
||||
middleware := CacheInvalidationMiddleware(cache)
|
||||
|
||||
t.Run("POST clears cache", func(t *testing.T) {
|
||||
request := httptest.NewRequest("POST", "/api/posts", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(recorder, request)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
for _, e := range entries {
|
||||
if _, err := cache.Get(e.key); err == nil {
|
||||
t.Errorf("Expected entry %s to be cleared, but it still exists", e.key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, e := range entries {
|
||||
if err := cache.Set(e.key, e.entry); err != nil {
|
||||
t.Fatalf("Failed to repopulate cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("PUT clears cache", func(t *testing.T) {
|
||||
request := httptest.NewRequest("PUT", "/api/posts/1", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(recorder, request)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
for _, e := range entries {
|
||||
if _, err := cache.Get(e.key); err == nil {
|
||||
t.Errorf("Expected entry %s to be cleared, but it still exists", e.key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, e := range entries {
|
||||
if err := cache.Set(e.key, e.entry); err != nil {
|
||||
t.Fatalf("Failed to repopulate cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("DELETE clears cache", func(t *testing.T) {
|
||||
request := httptest.NewRequest("DELETE", "/api/posts/1", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(recorder, request)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
for _, e := range entries {
|
||||
if _, err := cache.Get(e.key); err == nil {
|
||||
t.Errorf("Expected entry %s to be cleared, but it still exists", e.key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET does not clear cache", func(t *testing.T) {
|
||||
|
||||
for _, e := range entries {
|
||||
if err := cache.Set(e.key, e.entry); err != nil {
|
||||
t.Fatalf("Failed to repopulate cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(recorder, request)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
for _, e := range entries {
|
||||
if _, err := cache.Get(e.key); err != nil {
|
||||
t.Errorf("Expected entry %s to still exist, got error: %v", e.key, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CompressionMiddleware() func(http.Handler) http.Handler {
|
||||
return CompressionMiddlewareWithConfig(nil)
|
||||
}
|
||||
|
||||
func CompressionMiddlewareWithConfig(config *CompressionConfig) func(http.Handler) http.Handler {
|
||||
if config == nil {
|
||||
config = DefaultCompressionConfig()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !shouldCompress(r, config) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
bufferedWriter := &bufferedResponseWriter{
|
||||
ResponseWriter: w,
|
||||
buffer: &buf,
|
||||
}
|
||||
|
||||
next.ServeHTTP(bufferedWriter, r)
|
||||
|
||||
if buf.Len() < config.MinSize {
|
||||
bufferedWriter.flush()
|
||||
w.Write(buf.Bytes())
|
||||
return
|
||||
}
|
||||
|
||||
responseContentType := w.Header().Get("Content-Type")
|
||||
if !shouldCompressResponse(responseContentType, config) {
|
||||
bufferedWriter.flush()
|
||||
w.Write(buf.Bytes())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
bufferedWriter.flush()
|
||||
|
||||
gz, err := gzip.NewWriterLevel(w, config.Level)
|
||||
if err != nil {
|
||||
gz = gzip.NewWriter(w)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
if _, err := gz.Write(buf.Bytes()); err != nil {
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type bufferedResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
buffer *bytes.Buffer
|
||||
statusCode int
|
||||
headerWritten bool
|
||||
}
|
||||
|
||||
func (brw *bufferedResponseWriter) Write(b []byte) (int, error) {
|
||||
if !brw.headerWritten {
|
||||
brw.statusCode = http.StatusOK
|
||||
}
|
||||
return brw.buffer.Write(b)
|
||||
}
|
||||
|
||||
func (brw *bufferedResponseWriter) WriteHeader(code int) {
|
||||
if brw.headerWritten {
|
||||
return
|
||||
}
|
||||
brw.statusCode = code
|
||||
}
|
||||
|
||||
func (brw *bufferedResponseWriter) Header() http.Header {
|
||||
return brw.ResponseWriter.Header()
|
||||
}
|
||||
|
||||
func (brw *bufferedResponseWriter) flush() {
|
||||
if !brw.headerWritten {
|
||||
brw.ResponseWriter.WriteHeader(brw.statusCode)
|
||||
brw.headerWritten = true
|
||||
}
|
||||
}
|
||||
|
||||
func shouldCompress(r *http.Request, config *CompressionConfig) bool {
|
||||
return r.Header.Get("Content-Encoding") == ""
|
||||
}
|
||||
|
||||
func shouldCompressResponse(contentType string, config *CompressionConfig) bool {
|
||||
if contentType == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
compressible := false
|
||||
for _, compressibleType := range config.CompressibleTypes {
|
||||
if strings.HasPrefix(contentType, compressibleType) {
|
||||
compressible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !compressible {
|
||||
return false
|
||||
}
|
||||
|
||||
nonCompressiblePrefixes := []string{"image/", "video/", "audio/"}
|
||||
nonCompressibleExact := []string{"application/zip", "application/gzip"}
|
||||
|
||||
for _, prefix := range nonCompressiblePrefixes {
|
||||
if strings.HasPrefix(contentType, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return !slices.Contains(nonCompressibleExact, contentType)
|
||||
}
|
||||
|
||||
func DecompressionMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Content-Encoding") == "gzip" {
|
||||
gz, err := gzip.NewReader(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid gzip encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
r.Body = io.NopCloser(gz)
|
||||
r.Header.Del("Content-Encoding")
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type CompressionConfig struct {
|
||||
Level int
|
||||
MinSize int
|
||||
CompressibleTypes []string
|
||||
}
|
||||
|
||||
func DefaultCompressionConfig() *CompressionConfig {
|
||||
return &CompressionConfig{
|
||||
Level: gzip.DefaultCompression,
|
||||
MinSize: 0,
|
||||
CompressibleTypes: []string{
|
||||
"text/",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/javascript",
|
||||
"application/css",
|
||||
"application/",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompressionMiddleware(t *testing.T) {
|
||||
middleware := CompressionMiddleware()
|
||||
|
||||
t.Run("Accepts gzip encoding", func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Error("Expected Content-Encoding to be gzip")
|
||||
}
|
||||
|
||||
if !isGzipCompressed(recorder.Body.Bytes()) {
|
||||
t.Error("Expected response to be gzip compressed")
|
||||
}
|
||||
|
||||
decompressed, err := decompressGzip(recorder.Body.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decompress response: %v", err)
|
||||
}
|
||||
|
||||
if string(decompressed) != "test response" {
|
||||
t.Errorf("Expected decompressed content to be 'test response', got '%s'", string(decompressed))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Does not accept gzip encoding", func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "deflate")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") == "gzip" {
|
||||
t.Error("Expected Content-Encoding not to be gzip")
|
||||
}
|
||||
|
||||
if recorder.Body.String() != "test response" {
|
||||
t.Errorf("Expected 'test response', got '%s'", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No Accept-Encoding header", func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") == "gzip" {
|
||||
t.Error("Expected Content-Encoding not to be gzip")
|
||||
}
|
||||
|
||||
if recorder.Body.String() != "test response" {
|
||||
t.Errorf("Expected 'test response', got '%s'", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Small response compressed", func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("hi"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Error("Expected small response to be compressed")
|
||||
}
|
||||
|
||||
decompressed, err := decompressGzip(recorder.Body.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decompress response: %v", err)
|
||||
}
|
||||
|
||||
if string(decompressed) != "hi" {
|
||||
t.Errorf("Expected decompressed content to be 'hi', got '%s'", string(decompressed))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Already compressed response", func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
request.Header.Set("Content-Encoding", "gzip")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("already compressed"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") == "gzip" {
|
||||
t.Error("Expected Content-Encoding not to be gzip for already compressed request")
|
||||
}
|
||||
|
||||
if recorder.Body.String() != "already compressed" {
|
||||
t.Errorf("Expected 'already compressed', got '%s'", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShouldCompress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request *http.Request
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "GET request with gzip encoding",
|
||||
request: func() *http.Request {
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
return request
|
||||
}(),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "POST request with gzip encoding",
|
||||
request: func() *http.Request {
|
||||
request := httptest.NewRequest("POST", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
return request
|
||||
}(),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GET request without gzip encoding",
|
||||
request: func() *http.Request {
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "deflate")
|
||||
return request
|
||||
}(),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GET request for image",
|
||||
request: func() *http.Request {
|
||||
request := httptest.NewRequest("GET", "/image.jpg", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
request.Header.Set("Content-Type", "image/jpeg")
|
||||
return request
|
||||
}(),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GET request for CSS",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/style.css", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
req.Header.Set("Content-Type", "text/css")
|
||||
return req
|
||||
}(),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GET request for JavaScript",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/script.js", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
req.Header.Set("Content-Type", "application/javascript")
|
||||
return req
|
||||
}(),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := DefaultCompressionConfig()
|
||||
result := shouldCompress(tt.request, config)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isGzipCompressed(data []byte) bool {
|
||||
if len(data) < 2 {
|
||||
return false
|
||||
}
|
||||
return data[0] == 0x1f && data[1] == 0x8b
|
||||
}
|
||||
|
||||
func decompressGzip(data []byte) ([]byte, error) {
|
||||
reader, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
func TestCompressionMiddlewareWithConfig(t *testing.T) {
|
||||
t.Run("With default config", func(t *testing.T) {
|
||||
config := DefaultCompressionConfig()
|
||||
middleware := CompressionMiddlewareWithConfig(config)
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
request.Header.Set("Content-Type", "text/html")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Error("Expected Content-Encoding to be gzip")
|
||||
}
|
||||
|
||||
if !isGzipCompressed(recorder.Body.Bytes()) {
|
||||
t.Error("Expected response to be gzip compressed")
|
||||
}
|
||||
|
||||
decompressed, err := decompressGzip(recorder.Body.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decompress response: %v", err)
|
||||
}
|
||||
|
||||
if string(decompressed) != "test response" {
|
||||
t.Errorf("Expected decompressed content to be 'test response', got '%s'", string(decompressed))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("With custom config", func(t *testing.T) {
|
||||
config := &CompressionConfig{
|
||||
Level: gzip.BestCompression,
|
||||
MinSize: 0,
|
||||
CompressibleTypes: []string{
|
||||
"text/",
|
||||
"application/json",
|
||||
},
|
||||
}
|
||||
middleware := CompressionMiddlewareWithConfig(config)
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Error("Expected Content-Encoding to be gzip")
|
||||
}
|
||||
|
||||
if !isGzipCompressed(recorder.Body.Bytes()) {
|
||||
t.Error("Expected response to be gzip compressed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("With nil config uses default", func(t *testing.T) {
|
||||
middleware := CompressionMiddlewareWithConfig(nil)
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
request.Header.Set("Content-Type", "text/html")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Error("Expected Content-Encoding to be gzip")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Non-compressible content type", func(t *testing.T) {
|
||||
config := DefaultCompressionConfig()
|
||||
middleware := CompressionMiddlewareWithConfig(config)
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") == "gzip" {
|
||||
t.Error("Expected Content-Encoding not to be gzip for non-compressible content")
|
||||
}
|
||||
|
||||
if recorder.Body.String() != "test response" {
|
||||
t.Errorf("Expected 'test response', got '%s'", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Minimum size threshold - small response not compressed", func(t *testing.T) {
|
||||
config := &CompressionConfig{
|
||||
Level: gzip.DefaultCompression,
|
||||
MinSize: 1000,
|
||||
CompressibleTypes: []string{
|
||||
"text/",
|
||||
},
|
||||
}
|
||||
middleware := CompressionMiddlewareWithConfig(config)
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
request.Header.Set("Content-Type", "text/html")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("small"))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") == "gzip" {
|
||||
t.Error("Expected Content-Encoding not to be gzip for small response")
|
||||
}
|
||||
|
||||
if recorder.Body.String() != "small" {
|
||||
t.Errorf("Expected 'small', got '%s'", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Minimum size threshold - large response compressed", func(t *testing.T) {
|
||||
config := &CompressionConfig{
|
||||
Level: gzip.DefaultCompression,
|
||||
MinSize: 10,
|
||||
CompressibleTypes: []string{
|
||||
"text/",
|
||||
},
|
||||
}
|
||||
middleware := CompressionMiddlewareWithConfig(config)
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
request.Header.Set("Content-Type", "text/html")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
largeResponse := strings.Repeat("a", 100)
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(largeResponse))
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Error("Expected Content-Encoding to be gzip for large response")
|
||||
}
|
||||
|
||||
if !isGzipCompressed(recorder.Body.Bytes()) {
|
||||
t.Error("Expected response to be gzip compressed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecompressionMiddleware(t *testing.T) {
|
||||
t.Run("Decompresses gzip request body", func(t *testing.T) {
|
||||
middleware := DecompressionMiddleware()
|
||||
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
gz.Write([]byte("compressed data"))
|
||||
gz.Close()
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", &buf)
|
||||
request.Header.Set("Content-Encoding", "gzip")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read request body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(body)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Body.String() != "compressed data" {
|
||||
t.Errorf("Expected 'compressed data', got '%s'", recorder.Body.String())
|
||||
}
|
||||
|
||||
if request.Header.Get("Content-Encoding") != "" {
|
||||
t.Error("Expected Content-Encoding header to be removed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Handles non-gzip request", func(t *testing.T) {
|
||||
middleware := DecompressionMiddleware()
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", strings.NewReader("plain data"))
|
||||
request.Header.Set("Content-Type", "text/plain")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read request body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(body)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Body.String() != "plain data" {
|
||||
t.Errorf("Expected 'plain data', got '%s'", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Handles invalid gzip data", func(t *testing.T) {
|
||||
middleware := DecompressionMiddleware()
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", strings.NewReader("invalid gzip data"))
|
||||
request.Header.Set("Content-Encoding", "gzip")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("Handler should not be called for invalid gzip data")
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), "Invalid gzip encoding") {
|
||||
t.Error("Expected error message about invalid gzip encoding")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Handles empty request body", func(t *testing.T) {
|
||||
middleware := DecompressionMiddleware()
|
||||
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
gz.Close()
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", &buf)
|
||||
request.Header.Set("Content-Encoding", "gzip")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read request body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(body)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Body.String() != "" {
|
||||
t.Errorf("Expected empty body, got '%s'", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShouldCompressWithConfig(t *testing.T) {
|
||||
config := DefaultCompressionConfig()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
request *http.Request
|
||||
config *CompressionConfig
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Compressible content type",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Content-Type", "text/html")
|
||||
return req
|
||||
}(),
|
||||
config: config,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Non-compressible content type",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
return req
|
||||
}(),
|
||||
config: config,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Already compressed request",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Content-Type", "text/html")
|
||||
req.Header.Set("Content-Encoding", "gzip")
|
||||
return req
|
||||
}(),
|
||||
config: config,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Custom compressible types",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Content-Type", "application/custom")
|
||||
return req
|
||||
}(),
|
||||
config: &CompressionConfig{
|
||||
CompressibleTypes: []string{"application/custom"},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Non-compressible exact match",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Content-Type", "application/zip")
|
||||
return req
|
||||
}(),
|
||||
config: config,
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := shouldCompress(tt.request, tt.config)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCompressionConfig(t *testing.T) {
|
||||
config := DefaultCompressionConfig()
|
||||
|
||||
if config.Level != gzip.DefaultCompression {
|
||||
t.Errorf("Expected level %d, got %d", gzip.DefaultCompression, config.Level)
|
||||
}
|
||||
|
||||
if config.MinSize != 0 {
|
||||
t.Errorf("Expected min size 0, got %d", config.MinSize)
|
||||
}
|
||||
|
||||
expectedTypes := []string{
|
||||
"text/",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/javascript",
|
||||
"application/css",
|
||||
"application/",
|
||||
}
|
||||
|
||||
if len(config.CompressibleTypes) != len(expectedTypes) {
|
||||
t.Errorf("Expected %d compressible types, got %d", len(expectedTypes), len(config.CompressibleTypes))
|
||||
}
|
||||
|
||||
for i, expectedType := range expectedTypes {
|
||||
if config.CompressibleTypes[i] != expectedType {
|
||||
t.Errorf("Expected compressible type %s at index %d, got %s", expectedType, i, config.CompressibleTypes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins []string
|
||||
AllowedMethods []string
|
||||
AllowedHeaders []string
|
||||
MaxAge int
|
||||
AllowCredentials bool
|
||||
}
|
||||
|
||||
func NewCORSConfig() *CORSConfig {
|
||||
env := os.Getenv("GOYCO_ENV")
|
||||
|
||||
config := &CORSConfig{
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Requested-With", "X-CSRF-Token"},
|
||||
MaxAge: 86400,
|
||||
AllowCredentials: false,
|
||||
}
|
||||
|
||||
switch env {
|
||||
case "production":
|
||||
if origins := os.Getenv("CORS_ALLOWED_ORIGINS"); origins == "" {
|
||||
config.AllowedOrigins = []string{}
|
||||
}
|
||||
config.AllowCredentials = true
|
||||
case "staging":
|
||||
if origins := os.Getenv("CORS_ALLOWED_ORIGINS"); origins == "" {
|
||||
config.AllowedOrigins = []string{}
|
||||
}
|
||||
config.AllowCredentials = true
|
||||
default:
|
||||
config.AllowedOrigins = []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:8080",
|
||||
}
|
||||
config.AllowCredentials = true
|
||||
}
|
||||
|
||||
if origins := os.Getenv("CORS_ALLOWED_ORIGINS"); origins != "" {
|
||||
config.AllowedOrigins = strings.Split(origins, ",")
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func CORSWithConfig(config *CORSConfig) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
if origin != "" {
|
||||
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)
|
||||
}
|
||||
|
||||
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-Max-Age", fmt.Sprintf("%d", config.MaxAge))
|
||||
|
||||
if config.AllowCredentials && !hasWildcard {
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if origin != "" {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
config := NewCORSConfig()
|
||||
return CORSWithConfig(config)(next)
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCORSWithAuthHeader(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"http://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization"},
|
||||
MaxAge: 3600,
|
||||
AllowCredentials: true,
|
||||
}
|
||||
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
origin string
|
||||
path string
|
||||
hasAuth bool
|
||||
expectedOrigin string
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "Allowed origin with auth on API path",
|
||||
origin: "http://example.com",
|
||||
path: "/api/test",
|
||||
hasAuth: true,
|
||||
expectedOrigin: "http://example.com",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Disallowed origin with auth on API path",
|
||||
origin: "http://malicious.com",
|
||||
path: "/api/test",
|
||||
hasAuth: true,
|
||||
expectedOrigin: "",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Allowed origin without auth on API path",
|
||||
origin: "http://example.com",
|
||||
path: "/api/test",
|
||||
hasAuth: false,
|
||||
expectedOrigin: "http://example.com",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Disallowed origin without auth on API path",
|
||||
origin: "http://malicious.com",
|
||||
path: "/api/test",
|
||||
hasAuth: false,
|
||||
expectedOrigin: "",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "Allowed origin with auth on non-API path",
|
||||
origin: "http://example.com",
|
||||
path: "/public/page",
|
||||
hasAuth: true,
|
||||
expectedOrigin: "http://example.com",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Disallowed origin with auth on non-API path",
|
||||
origin: "http://malicious.com",
|
||||
path: "/public/page",
|
||||
hasAuth: true,
|
||||
expectedOrigin: "",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tc.path, nil)
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
if tc.hasAuth {
|
||||
req.Header.Set("Authorization", "Bearer fake-token")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tc.expectedStatus, w.Code)
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != tc.expectedOrigin {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be '%s', got '%s'",
|
||||
tc.expectedOrigin, w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSWithConfig_AllowedOrigin(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"http://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: 3600,
|
||||
AllowCredentials: true,
|
||||
}
|
||||
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Origin", "http://example.com")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "http://example.com" {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be 'http://example.com', got '%s'", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Errorf("Expected Access-Control-Allow-Credentials to be 'true', got '%s'", w.Header().Get("Access-Control-Allow-Credentials"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSWithConfig_DisallowedOrigin(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"http://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: 3600,
|
||||
AllowCredentials: false,
|
||||
}
|
||||
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Origin", "http://malicious.com")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status 403 for disallowed origin, got %d", w.Code)
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be empty for disallowed origin, got '%s'", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSWithConfig_WildcardOrigin(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: 3600,
|
||||
AllowCredentials: false,
|
||||
}
|
||||
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Origin", "http://any-origin.com")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be '*', got '%s'", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Credentials") != "" {
|
||||
t.Errorf("Expected Access-Control-Allow-Credentials to be empty with wildcard, got '%s'", w.Header().Get("Access-Control-Allow-Credentials"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSWithConfig_WildcardWithCredentials(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: 3600,
|
||||
AllowCredentials: true,
|
||||
}
|
||||
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Origin", "http://example.com")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "http://example.com" {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be 'http://example.com', got '%s'", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Credentials") != "" {
|
||||
t.Errorf("Expected Access-Control-Allow-Credentials to be empty with wildcard, got '%s'", w.Header().Get("Access-Control-Allow-Credentials"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSWithConfig_NoOriginHeader(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"http://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: 3600,
|
||||
AllowCredentials: false,
|
||||
}
|
||||
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be empty, got '%s'", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSWithConfig_NoOriginWithWildcard(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: 3600,
|
||||
AllowCredentials: false,
|
||||
}
|
||||
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be empty (no origin in request), got '%s'", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSWithConfig_PreflightRequest(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"http://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization"},
|
||||
MaxAge: 86400,
|
||||
AllowCredentials: true,
|
||||
}
|
||||
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("Next handler should not be called for OPTIONS request")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "/api/test", nil)
|
||||
req.Header.Set("Origin", "http://example.com")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "http://example.com" {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be 'http://example.com', got '%s'", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Methods") != "GET, POST, PUT, DELETE" {
|
||||
t.Errorf("Expected Access-Control-Allow-Methods to be 'GET, POST, PUT, DELETE', got '%s'", w.Header().Get("Access-Control-Allow-Methods"))
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Headers") != "Content-Type, Authorization" {
|
||||
t.Errorf("Expected Access-Control-Allow-Headers to be 'Content-Type, Authorization', got '%s'", w.Header().Get("Access-Control-Allow-Headers"))
|
||||
}
|
||||
if w.Header().Get("Access-Control-Max-Age") != "86400" {
|
||||
t.Errorf("Expected Access-Control-Max-Age to be '86400', got '%s'", w.Header().Get("Access-Control-Max-Age"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSWithConfig_MultipleAllowedOrigins(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"http://example1.com", "http://example2.com", "http://example3.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: 3600,
|
||||
AllowCredentials: true,
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
origin string
|
||||
expected string
|
||||
status int
|
||||
}{
|
||||
{"http://example1.com", "http://example1.com", http.StatusOK},
|
||||
{"http://example2.com", "http://example2.com", http.StatusOK},
|
||||
{"http://example3.com", "http://example3.com", http.StatusOK},
|
||||
{"http://notallowed.com", "", http.StatusForbidden},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.origin, func(t *testing.T) {
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tc.status {
|
||||
t.Errorf("For origin '%s', expected status %d, got %d", tc.origin, tc.status, w.Code)
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != tc.expected {
|
||||
t.Errorf("For origin '%s', expected Access-Control-Allow-Origin to be '%s', got '%s'",
|
||||
tc.origin, tc.expected, w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSWithConfig_CORSHeaders(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: []string{"http://example.com"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Custom-Header"},
|
||||
MaxAge: 7200,
|
||||
AllowCredentials: true,
|
||||
}
|
||||
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.Header.Set("Origin", "http://example.com")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "http://example.com" {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be 'http://example.com', got '%s'", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Errorf("Expected Access-Control-Allow-Credentials to be 'true', got '%s'", w.Header().Get("Access-Control-Allow-Credentials"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestCORSOPTIONSRequest(t *testing.T) {
|
||||
t.Setenv("GOYCO_ENV", "development")
|
||||
t.Setenv("CORS_ALLOWED_ORIGINS", "http://localhost:3000,https://yourdomain.com")
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("should not reach handler"))
|
||||
})
|
||||
|
||||
middleware := CORS(handler)
|
||||
request := httptest.NewRequest("OPTIONS", "/api/posts", nil)
|
||||
request.Header.Set("Origin", "http://localhost:3000")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
middleware.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Body.String() != "" {
|
||||
t.Error("OPTIONS request should not reach the handler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSAllowedOrigins(t *testing.T) {
|
||||
t.Setenv("GOYCO_ENV", "development")
|
||||
t.Setenv("CORS_ALLOWED_ORIGINS", "http://localhost:3000,https://yourdomain.com")
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
middleware := CORS(handler)
|
||||
|
||||
allowedOrigins := []string{
|
||||
"http://localhost:3000",
|
||||
"https://yourdomain.com",
|
||||
}
|
||||
|
||||
unauthorizedOrigins := []string{
|
||||
"https://malicious.com",
|
||||
"http://evil.com",
|
||||
"https://attacker.net",
|
||||
}
|
||||
|
||||
for _, origin := range allowedOrigins {
|
||||
request := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
request.Header.Set("Origin", origin)
|
||||
request.Header.Set("Authorization", "Bearer token123")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
middleware.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Origin %s should be allowed, got status %d", origin, recorder.Code)
|
||||
}
|
||||
actualOrigin := recorder.Header().Get("Access-Control-Allow-Origin")
|
||||
if actualOrigin != origin {
|
||||
t.Errorf("Origin %s should be allowed, got Access-Control-Allow-Origin %s", origin, actualOrigin)
|
||||
}
|
||||
}
|
||||
|
||||
for _, origin := range unauthorizedOrigins {
|
||||
request := httptest.NewRequest("GET", "/api/auth/me", nil)
|
||||
request.Header.Set("Origin", origin)
|
||||
request.Header.Set("Authorization", "Bearer token123")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
middleware.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Errorf("Origin %s should be blocked (403), got status %d", origin, recorder.Code)
|
||||
}
|
||||
actualOrigin := recorder.Header().Get("Access-Control-Allow-Origin")
|
||||
if actualOrigin != "" {
|
||||
t.Errorf("Origin %s should be blocked, got Access-Control-Allow-Origin %s", origin, actualOrigin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSWithoutOrigin(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
allowedOrigins []string
|
||||
expectedAllowOrigin string
|
||||
shouldSetHeader bool
|
||||
}{
|
||||
{
|
||||
name: "No origin header with wildcard config",
|
||||
allowedOrigins: []string{"*"},
|
||||
expectedAllowOrigin: "",
|
||||
shouldSetHeader: false,
|
||||
},
|
||||
{
|
||||
name: "No origin header without wildcard config",
|
||||
allowedOrigins: []string{"http://example.com"},
|
||||
expectedAllowOrigin: "",
|
||||
shouldSetHeader: false,
|
||||
},
|
||||
{
|
||||
name: "No origin header with multiple specific origins",
|
||||
allowedOrigins: []string{"http://example1.com", "http://example2.com"},
|
||||
expectedAllowOrigin: "",
|
||||
shouldSetHeader: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
config := &CORSConfig{
|
||||
AllowedOrigins: tc.allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Content-Type"},
|
||||
MaxAge: 3600,
|
||||
AllowCredentials: false,
|
||||
}
|
||||
|
||||
handler := CORSWithConfig(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
actualOrigin := w.Header().Get("Access-Control-Allow-Origin")
|
||||
|
||||
if tc.shouldSetHeader {
|
||||
if actualOrigin != tc.expectedAllowOrigin {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be '%s', got '%s'",
|
||||
tc.expectedAllowOrigin, actualOrigin)
|
||||
}
|
||||
} else {
|
||||
if actualOrigin != "" {
|
||||
t.Errorf("Expected Access-Control-Allow-Origin to be empty (not set), got '%s'",
|
||||
actualOrigin)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
CSRFTokenCookieName = "csrf_token"
|
||||
CSRFTokenFormName = "csrf_token"
|
||||
CSRFTokenHeaderName = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
func CSRFToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func SetCSRFToken(w http.ResponseWriter, r *http.Request, token string) {
|
||||
cookie := &http.Cookie{
|
||||
Name: CSRFTokenCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 3600,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
func GetCSRFToken(r *http.Request) string {
|
||||
if token := strings.TrimSpace(r.FormValue(CSRFTokenFormName)); token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
if token := strings.TrimSpace(r.Header.Get(CSRFTokenHeaderName)); token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(CSRFTokenCookieName); err == nil {
|
||||
return strings.TrimSpace(cookie.Value)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func ValidateCSRFToken(r *http.Request) bool {
|
||||
formToken := GetCSRFToken(r)
|
||||
if formToken == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(CSRFTokenCookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
cookieToken := strings.TrimSpace(cookie.Value)
|
||||
if cookieToken == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return subtle.ConstantTimeCompare([]byte(formToken), []byte(cookieToken)) == 1
|
||||
}
|
||||
|
||||
func CSRFMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !ValidateCSRFToken(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isHTTPS(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if proto == "https" {
|
||||
return true
|
||||
}
|
||||
|
||||
ssl := r.Header.Get("X-Forwarded-Ssl")
|
||||
if ssl == "on" {
|
||||
return true
|
||||
}
|
||||
|
||||
scheme := r.Header.Get("X-Forwarded-Scheme")
|
||||
return scheme == "https"
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCSRFTokenGeneration(t *testing.T) {
|
||||
token1, err := CSRFToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSRF token: %v", err)
|
||||
}
|
||||
|
||||
token2, err := CSRFToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate second CSRF token: %v", err)
|
||||
}
|
||||
|
||||
if token1 == token2 {
|
||||
t.Error("Generated CSRF tokens should be unique")
|
||||
}
|
||||
|
||||
if token1 == "" || token2 == "" {
|
||||
t.Error("Generated CSRF tokens should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFTokenValidation(t *testing.T) {
|
||||
token, err := CSRFToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSRF token: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", nil)
|
||||
request.Form = make(map[string][]string)
|
||||
request.Form["csrf_token"] = []string{token}
|
||||
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: CSRFTokenCookieName,
|
||||
Value: token,
|
||||
})
|
||||
|
||||
if !ValidateCSRFToken(request) {
|
||||
t.Error("Valid CSRF token should pass validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFTokenValidationFailure(t *testing.T) {
|
||||
token1, err := CSRFToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate first CSRF token: %v", err)
|
||||
}
|
||||
|
||||
token2, err := CSRFToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate second CSRF token: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", nil)
|
||||
request.Form = make(map[string][]string)
|
||||
request.Form["csrf_token"] = []string{token1}
|
||||
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: CSRFTokenCookieName,
|
||||
Value: token2,
|
||||
})
|
||||
|
||||
if ValidateCSRFToken(request) {
|
||||
t.Error("Mismatched CSRF tokens should fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFTokenValidationMissingToken(t *testing.T) {
|
||||
request := httptest.NewRequest("POST", "/test", nil)
|
||||
|
||||
if ValidateCSRFToken(request) {
|
||||
t.Error("Request without CSRF token should fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFTokenValidationMissingCookie(t *testing.T) {
|
||||
token, err := CSRFToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSRF token: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", nil)
|
||||
request.Form = make(map[string][]string)
|
||||
request.Form["csrf_token"] = []string{token}
|
||||
|
||||
if ValidateCSRFToken(request) {
|
||||
t.Error("Request with token in form but no cookie should fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFTokenValidationHeader(t *testing.T) {
|
||||
token, err := CSRFToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSRF token: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", nil)
|
||||
request.Header.Set(CSRFTokenHeaderName, token)
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: CSRFTokenCookieName,
|
||||
Value: token,
|
||||
})
|
||||
|
||||
if !ValidateCSRFToken(request) {
|
||||
t.Error("Valid CSRF token in header should pass validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFMiddleware(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("GET request should be allowed through CSRF middleware, got status %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFMiddlewareBlocksInvalidToken(t *testing.T) {
|
||||
request := httptest.NewRequest("POST", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Errorf("POST request without valid CSRF token should be blocked, got status %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFMiddlewareAllowsValidToken(t *testing.T) {
|
||||
token, err := CSRFToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSRF token: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", nil)
|
||||
request.Form = make(map[string][]string)
|
||||
request.Form["csrf_token"] = []string{token}
|
||||
request.AddCookie(&http.Cookie{
|
||||
Name: CSRFTokenCookieName,
|
||||
Value: token,
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("POST request with valid CSRF token should be allowed, got status %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFMiddlewareSkipsAPI(t *testing.T) {
|
||||
request := httptest.NewRequest("POST", "/api/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := CSRFMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("API requests should skip CSRF validation, got status %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCSRFToken(t *testing.T) {
|
||||
token, err := CSRFToken()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSRF token: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
SetCSRFToken(recorder, request, token)
|
||||
|
||||
cookies := recorder.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("Expected CSRF token cookie to be set")
|
||||
}
|
||||
|
||||
cookie := cookies[0]
|
||||
if cookie.Name != CSRFTokenCookieName {
|
||||
t.Errorf("Expected cookie name %s, got %s", CSRFTokenCookieName, cookie.Name)
|
||||
}
|
||||
|
||||
if cookie.Value != token {
|
||||
t.Errorf("Expected cookie value %s, got %s", token, cookie.Value)
|
||||
}
|
||||
|
||||
if !cookie.HttpOnly {
|
||||
t.Error("CSRF token cookie should be HttpOnly")
|
||||
}
|
||||
|
||||
if cookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Errorf("Expected SameSite %v, got %v", http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dbMonitorKey contextKey = "db_monitor"
|
||||
slowQueryThresholdKey contextKey = "slow_query_threshold"
|
||||
)
|
||||
|
||||
type DBMonitor interface {
|
||||
LogQuery(query string, duration time.Duration, err error)
|
||||
LogSlowQuery(query string, duration time.Duration, threshold time.Duration)
|
||||
GetStats() DBStats
|
||||
}
|
||||
|
||||
type DBStats struct {
|
||||
TotalQueries int64 `json:"total_queries"`
|
||||
SlowQueries int64 `json:"slow_queries"`
|
||||
AverageDuration time.Duration `json:"average_duration"`
|
||||
MaxDuration time.Duration `json:"max_duration"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
LastQueryTime time.Time `json:"last_query_time"`
|
||||
}
|
||||
|
||||
type InMemoryDBMonitor struct {
|
||||
stats DBStats
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewInMemoryDBMonitor() *InMemoryDBMonitor {
|
||||
return &InMemoryDBMonitor{
|
||||
stats: DBStats{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *InMemoryDBMonitor) LogQuery(query string, duration time.Duration, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.stats.TotalQueries++
|
||||
m.stats.LastQueryTime = time.Now()
|
||||
|
||||
if err != nil {
|
||||
m.stats.ErrorCount++
|
||||
return
|
||||
}
|
||||
|
||||
if m.stats.TotalQueries == 1 {
|
||||
m.stats.AverageDuration = duration
|
||||
} else {
|
||||
|
||||
totalDuration := int64(m.stats.AverageDuration) * (m.stats.TotalQueries - 1)
|
||||
totalDuration += int64(duration)
|
||||
m.stats.AverageDuration = time.Duration(totalDuration / m.stats.TotalQueries)
|
||||
}
|
||||
|
||||
if duration > m.stats.MaxDuration {
|
||||
m.stats.MaxDuration = duration
|
||||
}
|
||||
|
||||
slowThreshold := 100 * time.Millisecond
|
||||
if duration > slowThreshold {
|
||||
m.stats.SlowQueries++
|
||||
}
|
||||
}
|
||||
|
||||
func (m *InMemoryDBMonitor) LogSlowQuery(query string, duration time.Duration, threshold time.Duration) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.stats.SlowQueries++
|
||||
}
|
||||
|
||||
func (m *InMemoryDBMonitor) GetStats() DBStats {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.stats
|
||||
}
|
||||
|
||||
func DBMonitoringMiddleware(monitor DBMonitor, slowQueryThreshold time.Duration) func(http.Handler) http.Handler {
|
||||
if slowQueryThreshold == 0 {
|
||||
slowQueryThreshold = 100 * time.Millisecond
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
ctx := context.WithValue(r.Context(), dbMonitorKey, monitor)
|
||||
ctx = context.WithValue(ctx, slowQueryThresholdKey, slowQueryThreshold)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
||||
duration := time.Since(start)
|
||||
if duration > slowQueryThreshold {
|
||||
|
||||
monitor.LogSlowQuery(r.URL.Path, duration, slowQueryThreshold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type QueryLogger struct {
|
||||
DB *sql.DB
|
||||
Monitor DBMonitor
|
||||
}
|
||||
|
||||
func NewQueryLogger(db *sql.DB, monitor DBMonitor) *QueryLogger {
|
||||
return &QueryLogger{
|
||||
DB: db,
|
||||
Monitor: monitor,
|
||||
}
|
||||
}
|
||||
|
||||
func (ql *QueryLogger) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
||||
start := time.Now()
|
||||
rows, err := ql.DB.QueryContext(ctx, query, args...)
|
||||
duration := time.Since(start)
|
||||
|
||||
ql.Monitor.LogQuery(query, duration, err)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (ql *QueryLogger) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
|
||||
start := time.Now()
|
||||
row := ql.DB.QueryRowContext(ctx, query, args...)
|
||||
duration := time.Since(start)
|
||||
|
||||
ql.Monitor.LogQuery(query, duration, nil)
|
||||
return row
|
||||
}
|
||||
|
||||
func (ql *QueryLogger) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
start := time.Now()
|
||||
result, err := ql.DB.ExecContext(ctx, query, args...)
|
||||
duration := time.Since(start)
|
||||
|
||||
ql.Monitor.LogQuery(query, duration, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
type DatabaseHealthChecker struct {
|
||||
DB *sql.DB
|
||||
Monitor DBMonitor
|
||||
}
|
||||
|
||||
func NewDatabaseHealthChecker(db *sql.DB, monitor DBMonitor) *DatabaseHealthChecker {
|
||||
return &DatabaseHealthChecker{
|
||||
DB: db,
|
||||
Monitor: monitor,
|
||||
}
|
||||
}
|
||||
|
||||
func (dhc *DatabaseHealthChecker) CheckHealth() map[string]any {
|
||||
start := time.Now()
|
||||
|
||||
err := dhc.DB.Ping()
|
||||
duration := time.Since(start)
|
||||
|
||||
health := map[string]any{
|
||||
"status": "healthy",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"ping_time": duration.String(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
health["status"] = "unhealthy"
|
||||
health["error"] = err.Error()
|
||||
return health
|
||||
}
|
||||
|
||||
stats := dhc.Monitor.GetStats()
|
||||
health["database_stats"] = map[string]any{
|
||||
"total_queries": stats.TotalQueries,
|
||||
"slow_queries": stats.SlowQueries,
|
||||
"average_duration": stats.AverageDuration.String(),
|
||||
"max_duration": stats.MaxDuration.String(),
|
||||
"error_count": stats.ErrorCount,
|
||||
"last_query_time": stats.LastQueryTime.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
return health
|
||||
}
|
||||
|
||||
type PerformanceMetrics struct {
|
||||
RequestCount int64 `json:"request_count"`
|
||||
AverageResponse time.Duration `json:"average_response"`
|
||||
MaxResponse time.Duration `json:"max_response"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
DBStats DBStats `json:"database_stats"`
|
||||
}
|
||||
|
||||
type MetricsCollector struct {
|
||||
monitor DBMonitor
|
||||
metrics PerformanceMetrics
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewMetricsCollector(monitor DBMonitor) *MetricsCollector {
|
||||
return &MetricsCollector{
|
||||
monitor: monitor,
|
||||
metrics: PerformanceMetrics{},
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) RecordRequest(duration time.Duration, hasError bool) {
|
||||
mc.mu.Lock()
|
||||
defer mc.mu.Unlock()
|
||||
|
||||
mc.metrics.RequestCount++
|
||||
|
||||
if hasError {
|
||||
mc.metrics.ErrorCount++
|
||||
}
|
||||
|
||||
if mc.metrics.RequestCount == 1 {
|
||||
mc.metrics.AverageResponse = duration
|
||||
} else {
|
||||
|
||||
totalDuration := int64(mc.metrics.AverageResponse) * (mc.metrics.RequestCount - 1)
|
||||
totalDuration += int64(duration)
|
||||
mc.metrics.AverageResponse = time.Duration(totalDuration / mc.metrics.RequestCount)
|
||||
}
|
||||
|
||||
if duration > mc.metrics.MaxResponse {
|
||||
mc.metrics.MaxResponse = duration
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) GetMetrics() PerformanceMetrics {
|
||||
mc.mu.RLock()
|
||||
defer mc.mu.RUnlock()
|
||||
|
||||
mc.metrics.DBStats = mc.monitor.GetStats()
|
||||
return mc.metrics
|
||||
}
|
||||
|
||||
func MetricsMiddleware(collector *MetricsCollector) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
rw := &metricsResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
hasError := rw.statusCode >= 400
|
||||
collector.RecordRequest(duration, hasError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type metricsResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *metricsResponseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func GetDBMonitorFromContext(ctx context.Context) (DBMonitor, bool) {
|
||||
monitor, ok := ctx.Value(dbMonitorKey).(DBMonitor)
|
||||
return monitor, ok
|
||||
}
|
||||
|
||||
func GetSlowQueryThresholdFromContext(ctx context.Context) (time.Duration, bool) {
|
||||
threshold, ok := ctx.Value(slowQueryThresholdKey).(time.Duration)
|
||||
return threshold, ok
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func TestInMemoryDBMonitor(t *testing.T) {
|
||||
monitor := NewInMemoryDBMonitor()
|
||||
|
||||
stats := monitor.GetStats()
|
||||
if stats.TotalQueries != 0 {
|
||||
t.Errorf("Expected 0 total queries, got %d", stats.TotalQueries)
|
||||
}
|
||||
|
||||
monitor.LogQuery("SELECT * FROM users", 50*time.Millisecond, nil)
|
||||
stats = monitor.GetStats()
|
||||
if stats.TotalQueries != 1 {
|
||||
t.Errorf("Expected 1 total query, got %d", stats.TotalQueries)
|
||||
}
|
||||
if stats.AverageDuration != 50*time.Millisecond {
|
||||
t.Errorf("Expected average duration 50ms, got %v", stats.AverageDuration)
|
||||
}
|
||||
if stats.MaxDuration != 50*time.Millisecond {
|
||||
t.Errorf("Expected max duration 50ms, got %v", stats.MaxDuration)
|
||||
}
|
||||
|
||||
monitor.LogQuery("SELECT * FROM posts", 150*time.Millisecond, nil)
|
||||
stats = monitor.GetStats()
|
||||
if stats.TotalQueries != 2 {
|
||||
t.Errorf("Expected 2 total queries, got %d", stats.TotalQueries)
|
||||
}
|
||||
if stats.SlowQueries != 1 {
|
||||
t.Errorf("Expected 1 slow query, got %d", stats.SlowQueries)
|
||||
}
|
||||
|
||||
monitor.LogQuery("SELECT * FROM invalid", 10*time.Millisecond, sql.ErrNoRows)
|
||||
stats = monitor.GetStats()
|
||||
if stats.TotalQueries != 3 {
|
||||
t.Errorf("Expected 3 total queries, got %d", stats.TotalQueries)
|
||||
}
|
||||
if stats.ErrorCount != 1 {
|
||||
t.Errorf("Expected 1 error, got %d", stats.ErrorCount)
|
||||
}
|
||||
|
||||
expectedAvg := time.Duration((int64(50*time.Millisecond) + int64(150*time.Millisecond)) / 2)
|
||||
if stats.AverageDuration != expectedAvg {
|
||||
t.Errorf("Expected average duration %v, got %v", expectedAvg, stats.AverageDuration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryLogger(t *testing.T) {
|
||||
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test table: %v", err)
|
||||
}
|
||||
|
||||
monitor := NewInMemoryDBMonitor()
|
||||
logger := NewQueryLogger(db, monitor)
|
||||
|
||||
ctx := context.Background()
|
||||
rows, err := logger.QueryContext(ctx, "SELECT * FROM users")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if rows == nil {
|
||||
t.Fatal("Expected rows, got nil")
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
stats := monitor.GetStats()
|
||||
if stats.TotalQueries != 1 {
|
||||
t.Errorf("Expected 1 total query, got %d", stats.TotalQueries)
|
||||
}
|
||||
|
||||
row := logger.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", 1)
|
||||
if row == nil {
|
||||
t.Fatal("Expected row, got nil")
|
||||
}
|
||||
|
||||
stats = monitor.GetStats()
|
||||
if stats.TotalQueries != 2 {
|
||||
t.Errorf("Expected 2 total queries, got %d", stats.TotalQueries)
|
||||
}
|
||||
|
||||
_, err = logger.ExecContext(ctx, "INSERT INTO users (name) VALUES (?)", "test")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for INSERT into non-existent table")
|
||||
}
|
||||
|
||||
stats = monitor.GetStats()
|
||||
if stats.TotalQueries != 3 {
|
||||
t.Errorf("Expected 3 total queries, got %d", stats.TotalQueries)
|
||||
}
|
||||
if stats.ErrorCount != 1 {
|
||||
t.Errorf("Expected 1 error, got %d", stats.ErrorCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseHealthChecker(t *testing.T) {
|
||||
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
monitor := NewInMemoryDBMonitor()
|
||||
checker := NewDatabaseHealthChecker(db, monitor)
|
||||
|
||||
health := checker.CheckHealth()
|
||||
if health["status"] != "healthy" {
|
||||
t.Errorf("Expected healthy status, got %v", health["status"])
|
||||
}
|
||||
if health["ping_time"] == nil {
|
||||
t.Error("Expected ping_time to be present")
|
||||
}
|
||||
|
||||
monitor.LogQuery("SELECT * FROM users", 50*time.Millisecond, nil)
|
||||
monitor.LogQuery("SELECT * FROM posts", 150*time.Millisecond, nil)
|
||||
|
||||
health = checker.CheckHealth()
|
||||
if health["database_stats"] == nil {
|
||||
t.Error("Expected database_stats to be present")
|
||||
}
|
||||
|
||||
stats, ok := health["database_stats"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("Expected database_stats to be a map")
|
||||
}
|
||||
|
||||
if stats["total_queries"] != int64(2) {
|
||||
t.Errorf("Expected 2 total queries, got %v", stats["total_queries"])
|
||||
}
|
||||
if stats["slow_queries"] != int64(1) {
|
||||
t.Errorf("Expected 1 slow query, got %v", stats["slow_queries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsCollector(t *testing.T) {
|
||||
monitor := NewInMemoryDBMonitor()
|
||||
collector := NewMetricsCollector(monitor)
|
||||
|
||||
metrics := collector.GetMetrics()
|
||||
if metrics.RequestCount != 0 {
|
||||
t.Errorf("Expected 0 requests, got %d", metrics.RequestCount)
|
||||
}
|
||||
|
||||
collector.RecordRequest(100*time.Millisecond, false)
|
||||
collector.RecordRequest(200*time.Millisecond, false)
|
||||
collector.RecordRequest(50*time.Millisecond, true)
|
||||
|
||||
metrics = collector.GetMetrics()
|
||||
if metrics.RequestCount != 3 {
|
||||
t.Errorf("Expected 3 requests, got %d", metrics.RequestCount)
|
||||
}
|
||||
if metrics.ErrorCount != 1 {
|
||||
t.Errorf("Expected 1 error, got %d", metrics.ErrorCount)
|
||||
}
|
||||
if metrics.MaxResponse != 200*time.Millisecond {
|
||||
t.Errorf("Expected max response 200ms, got %v", metrics.MaxResponse)
|
||||
}
|
||||
|
||||
expectedAvg := time.Duration((int64(100*time.Millisecond) + int64(200*time.Millisecond) + int64(50*time.Millisecond)) / 3)
|
||||
if metrics.AverageResponse != expectedAvg {
|
||||
t.Errorf("Expected average response %v, got %v", expectedAvg, metrics.AverageResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsMiddleware(t *testing.T) {
|
||||
monitor := NewInMemoryDBMonitor()
|
||||
collector := NewMetricsCollector(monitor)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
})
|
||||
|
||||
middleware := MetricsMiddleware(collector)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
metrics := collector.GetMetrics()
|
||||
if metrics.RequestCount != 1 {
|
||||
t.Errorf("Expected 1 request, got %d", metrics.RequestCount)
|
||||
}
|
||||
if metrics.ErrorCount != 0 {
|
||||
t.Errorf("Expected 0 errors, got %d", metrics.ErrorCount)
|
||||
}
|
||||
|
||||
errorHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("error"))
|
||||
})
|
||||
|
||||
errorMiddleware := MetricsMiddleware(collector)
|
||||
errorWrappedHandler := errorMiddleware(errorHandler)
|
||||
|
||||
req = httptest.NewRequest("GET", "/error", nil)
|
||||
w = httptest.NewRecorder()
|
||||
errorWrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status 500, got %d", w.Code)
|
||||
}
|
||||
|
||||
metrics = collector.GetMetrics()
|
||||
if metrics.RequestCount != 2 {
|
||||
t.Errorf("Expected 2 requests, got %d", metrics.RequestCount)
|
||||
}
|
||||
if metrics.ErrorCount != 1 {
|
||||
t.Errorf("Expected 1 error, got %d", metrics.ErrorCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBMonitoringMiddleware(t *testing.T) {
|
||||
monitor := NewInMemoryDBMonitor()
|
||||
threshold := 50 * time.Millisecond
|
||||
|
||||
var capturedCtx context.Context
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedCtx = r.Context()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
})
|
||||
|
||||
middleware := DBMonitoringMiddleware(monitor, threshold)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if capturedCtx == nil {
|
||||
t.Fatal("Expected context to be captured")
|
||||
}
|
||||
if capturedCtx.Value(dbMonitorKey) == nil {
|
||||
t.Error("Expected dbMonitorKey to be set in context")
|
||||
}
|
||||
if capturedCtx.Value(slowQueryThresholdKey) == nil {
|
||||
t.Error("Expected slowQueryThresholdKey to be set in context")
|
||||
}
|
||||
|
||||
actualThreshold := capturedCtx.Value(slowQueryThresholdKey).(time.Duration)
|
||||
if actualThreshold != threshold {
|
||||
t.Errorf("Expected threshold %v, got %v", threshold, actualThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsResponseWriter(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
writer := &metricsResponseWriter{
|
||||
ResponseWriter: recorder,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
if writer.statusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected status code %d, got %d", http.StatusNotFound, writer.statusCode)
|
||||
}
|
||||
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected underlying writer to receive status %d, got %d", http.StatusNotFound, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowQueryThreshold(t *testing.T) {
|
||||
monitor := NewInMemoryDBMonitor()
|
||||
|
||||
monitor.LogQuery("SELECT * FROM users", 50*time.Millisecond, nil)
|
||||
monitor.LogQuery("SELECT * FROM posts", 150*time.Millisecond, nil)
|
||||
monitor.LogQuery("SELECT * FROM comments", 200*time.Millisecond, nil)
|
||||
|
||||
stats := monitor.GetStats()
|
||||
if stats.SlowQueries != 2 {
|
||||
t.Errorf("Expected 2 slow queries with default 100ms threshold, got %d", stats.SlowQueries)
|
||||
}
|
||||
|
||||
monitor2 := NewInMemoryDBMonitor()
|
||||
monitor2.LogQuery("SELECT * FROM users", 50*time.Millisecond, nil)
|
||||
monitor2.LogQuery("SELECT * FROM posts", 150*time.Millisecond, nil)
|
||||
|
||||
stats2 := monitor2.GetStats()
|
||||
if stats2.SlowQueries != 1 {
|
||||
t.Errorf("Expected 1 slow query with default 100ms threshold, got %d", stats2.SlowQueries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
monitor := NewInMemoryDBMonitor()
|
||||
collector := NewMetricsCollector(monitor)
|
||||
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
monitor.LogQuery("SELECT * FROM users", 50*time.Millisecond, nil)
|
||||
collector.RecordRequest(100*time.Millisecond, false)
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
stats := monitor.GetStats()
|
||||
if stats.TotalQueries != 10 {
|
||||
t.Errorf("Expected 10 total queries, got %d", stats.TotalQueries)
|
||||
}
|
||||
|
||||
metrics := collector.GetMetrics()
|
||||
if metrics.RequestCount != 10 {
|
||||
t.Errorf("Expected 10 requests, got %d", metrics.RequestCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextHelpers(t *testing.T) {
|
||||
monitor := NewInMemoryDBMonitor()
|
||||
threshold := 200 * time.Millisecond
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, dbMonitorKey, monitor)
|
||||
ctx = context.WithValue(ctx, slowQueryThresholdKey, threshold)
|
||||
|
||||
retrievedMonitor, ok := GetDBMonitorFromContext(ctx)
|
||||
if !ok {
|
||||
t.Error("Expected to retrieve monitor from context")
|
||||
}
|
||||
if retrievedMonitor != monitor {
|
||||
t.Error("Expected retrieved monitor to match original")
|
||||
}
|
||||
|
||||
retrievedThreshold, ok := GetSlowQueryThresholdFromContext(ctx)
|
||||
if !ok {
|
||||
t.Error("Expected to retrieve threshold from context")
|
||||
}
|
||||
if retrievedThreshold != threshold {
|
||||
t.Errorf("Expected threshold %v, got %v", threshold, retrievedThreshold)
|
||||
}
|
||||
|
||||
emptyCtx := context.Background()
|
||||
_, ok = GetDBMonitorFromContext(emptyCtx)
|
||||
if ok {
|
||||
t.Error("Expected not to retrieve monitor from empty context")
|
||||
}
|
||||
|
||||
_, ok = GetSlowQueryThresholdFromContext(emptyCtx)
|
||||
if ok {
|
||||
t.Error("Expected not to retrieve threshold from empty context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadSafety(t *testing.T) {
|
||||
monitor := NewInMemoryDBMonitor()
|
||||
collector := NewMetricsCollector(monitor)
|
||||
|
||||
numGoroutines := 100
|
||||
done := make(chan bool, numGoroutines)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(id int) {
|
||||
|
||||
if id%2 == 0 {
|
||||
monitor.LogQuery("SELECT * FROM users", time.Duration(id)*time.Millisecond, nil)
|
||||
collector.RecordRequest(time.Duration(id)*time.Millisecond, false)
|
||||
} else {
|
||||
monitor.LogQuery("SELECT * FROM users", time.Duration(id)*time.Millisecond, sql.ErrNoRows)
|
||||
collector.RecordRequest(time.Duration(id)*time.Millisecond, true)
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
stats := monitor.GetStats()
|
||||
if stats.TotalQueries != int64(numGoroutines) {
|
||||
t.Errorf("Expected %d total queries, got %d", numGoroutines, stats.TotalQueries)
|
||||
}
|
||||
|
||||
metrics := collector.GetMetrics()
|
||||
if metrics.RequestCount != int64(numGoroutines) {
|
||||
t.Errorf("Expected %d requests, got %d", numGoroutines, metrics.RequestCount)
|
||||
}
|
||||
|
||||
expectedErrors := int64(numGoroutines / 2)
|
||||
if stats.ErrorCount != expectedErrors {
|
||||
t.Errorf("Expected %d errors, got %d", expectedErrors, stats.ErrorCount)
|
||||
}
|
||||
if metrics.ErrorCount != expectedErrors {
|
||||
t.Errorf("Expected %d request errors, got %d", expectedErrors, metrics.ErrorCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Logging(debug bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
if debug {
|
||||
log.Printf(
|
||||
"%s %s %d %v %s",
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
wrapped.statusCode,
|
||||
duration,
|
||||
r.UserAgent(),
|
||||
)
|
||||
} else {
|
||||
if wrapped.statusCode >= 400 || duration > time.Second {
|
||||
log.Printf(
|
||||
"%s %s %d %v %s",
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
wrapped.statusCode,
|
||||
duration,
|
||||
r.UserAgent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoggingRecordsStatusAndLogs(t *testing.T) {
|
||||
originalOutput := log.Writer()
|
||||
defer log.SetOutput(originalOutput)
|
||||
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
|
||||
handler := Logging(true)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/logging-test", nil)
|
||||
request.Header.Set("User-Agent", "test-agent")
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Result().StatusCode != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
|
||||
logLine := buf.String()
|
||||
if !strings.Contains(logLine, "GET /logging-test 201") {
|
||||
t.Fatalf("expected log line to contain method, path and status, got %q", logLine)
|
||||
}
|
||||
|
||||
if !strings.Contains(logLine, "test-agent") {
|
||||
t.Fatalf("expected log line to contain user agent, got %q", logLine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseWriterWriteHeaderStoresStatus(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
wrapped := &responseWriter{ResponseWriter: recorder, statusCode: http.StatusOK}
|
||||
|
||||
wrapped.WriteHeader(http.StatusAccepted)
|
||||
|
||||
if wrapped.statusCode != http.StatusAccepted {
|
||||
t.Fatalf("expected stored status 202, got %d", wrapped.statusCode)
|
||||
}
|
||||
|
||||
if recorder.Result().StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("expected underlying writer to receive 202, got %d", recorder.Result().StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultMaxKeys = 10000
|
||||
|
||||
DefaultCleanupInterval = 5 * time.Minute
|
||||
|
||||
DefaultMaxStaleAge = 10 * time.Minute
|
||||
)
|
||||
|
||||
var TrustProxyHeaders = false
|
||||
|
||||
func SetTrustProxyHeaders(value bool) {
|
||||
TrustProxyHeaders = value
|
||||
}
|
||||
|
||||
type limiterKey struct {
|
||||
window time.Duration
|
||||
limit int
|
||||
}
|
||||
|
||||
var (
|
||||
limiterRegistry = make(map[limiterKey]*RateLimiter)
|
||||
registryMutex sync.RWMutex
|
||||
registryCleanup []*RateLimiter
|
||||
cleanupMutex sync.Mutex
|
||||
)
|
||||
|
||||
func getOrCreateLimiter(window time.Duration, limit int) *RateLimiter {
|
||||
key := limiterKey{window: window, limit: limit}
|
||||
|
||||
registryMutex.RLock()
|
||||
if limiter, exists := limiterRegistry[key]; exists {
|
||||
registryMutex.RUnlock()
|
||||
return limiter
|
||||
}
|
||||
registryMutex.RUnlock()
|
||||
|
||||
registryMutex.Lock()
|
||||
defer registryMutex.Unlock()
|
||||
|
||||
if limiter, exists := limiterRegistry[key]; exists {
|
||||
return limiter
|
||||
}
|
||||
|
||||
limiter := NewRateLimiter(window, limit)
|
||||
limiterRegistry[key] = limiter
|
||||
|
||||
cleanupMutex.Lock()
|
||||
registryCleanup = append(registryCleanup, limiter)
|
||||
cleanupMutex.Unlock()
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
func StopAllRateLimiters() {
|
||||
cleanupMutex.Lock()
|
||||
defer cleanupMutex.Unlock()
|
||||
|
||||
for _, limiter := range registryCleanup {
|
||||
limiter.StopCleanup()
|
||||
}
|
||||
registryCleanup = nil
|
||||
|
||||
registryMutex.Lock()
|
||||
limiterRegistry = make(map[limiterKey]*RateLimiter)
|
||||
registryMutex.Unlock()
|
||||
}
|
||||
|
||||
type clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
type realClock struct{}
|
||||
|
||||
func (c *realClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
type keyEntry struct {
|
||||
requests []time.Time
|
||||
lastAccess time.Time
|
||||
}
|
||||
|
||||
type RateLimiter struct {
|
||||
entries map[string]*keyEntry
|
||||
mutex sync.RWMutex
|
||||
window time.Duration
|
||||
limit int
|
||||
maxKeys int
|
||||
cleanupInterval time.Duration
|
||||
maxStaleAge time.Duration
|
||||
stopCleanup chan struct{}
|
||||
cleanupOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
clock clock
|
||||
}
|
||||
|
||||
func NewRateLimiter(window time.Duration, limit int) *RateLimiter {
|
||||
return NewRateLimiterWithConfig(window, limit, DefaultMaxKeys, DefaultCleanupInterval, DefaultMaxStaleAge)
|
||||
}
|
||||
|
||||
func NewRateLimiterWithConfig(window time.Duration, limit int, maxKeys int, cleanupInterval time.Duration, maxStaleAge time.Duration) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
entries: make(map[string]*keyEntry),
|
||||
window: window,
|
||||
limit: limit,
|
||||
maxKeys: maxKeys,
|
||||
cleanupInterval: cleanupInterval,
|
||||
maxStaleAge: maxStaleAge,
|
||||
stopCleanup: make(chan struct{}),
|
||||
clock: &realClock{},
|
||||
}
|
||||
|
||||
rl.StartCleanup()
|
||||
|
||||
return rl
|
||||
}
|
||||
|
||||
func newRateLimiterWithClock(window time.Duration, limit int, c clock) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
entries: make(map[string]*keyEntry),
|
||||
window: window,
|
||||
limit: limit,
|
||||
maxKeys: DefaultMaxKeys,
|
||||
cleanupInterval: DefaultCleanupInterval,
|
||||
maxStaleAge: DefaultMaxStaleAge,
|
||||
stopCleanup: make(chan struct{}),
|
||||
clock: c,
|
||||
}
|
||||
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Allow(key string) bool {
|
||||
rl.mutex.Lock()
|
||||
defer rl.mutex.Unlock()
|
||||
|
||||
now := rl.clock.Now()
|
||||
cutoff := now.Add(-rl.window)
|
||||
|
||||
var entry *keyEntry
|
||||
var exists bool
|
||||
|
||||
if entry, exists = rl.entries[key]; exists {
|
||||
|
||||
isStale := now.Sub(entry.lastAccess) > rl.maxStaleAge
|
||||
|
||||
var validRequests []time.Time
|
||||
for _, reqTime := range entry.requests {
|
||||
if reqTime.After(cutoff) {
|
||||
validRequests = append(validRequests, reqTime)
|
||||
}
|
||||
}
|
||||
entry.requests = validRequests
|
||||
|
||||
if len(entry.requests) == 0 && isStale {
|
||||
delete(rl.entries, key)
|
||||
exists = false
|
||||
} else {
|
||||
|
||||
entry.lastAccess = now
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
|
||||
if len(rl.entries) >= rl.maxKeys {
|
||||
|
||||
rl.evictLRU()
|
||||
}
|
||||
|
||||
entry = &keyEntry{
|
||||
requests: []time.Time{now},
|
||||
lastAccess: now,
|
||||
}
|
||||
rl.entries[key] = entry
|
||||
return true
|
||||
}
|
||||
|
||||
requestCount := len(entry.requests)
|
||||
if requestCount >= rl.limit {
|
||||
return false
|
||||
}
|
||||
|
||||
entry.requests = append(entry.requests, now)
|
||||
entry.lastAccess = now
|
||||
return true
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) evictLRU() {
|
||||
if len(rl.entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var oldestKey string
|
||||
var oldestTime time.Time
|
||||
first := true
|
||||
|
||||
for key, entry := range rl.entries {
|
||||
if first || entry.lastAccess.Before(oldestTime) {
|
||||
oldestKey = key
|
||||
oldestTime = entry.lastAccess
|
||||
first = false
|
||||
}
|
||||
}
|
||||
|
||||
if oldestKey != "" {
|
||||
delete(rl.entries, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) GetRemainingTime(key string) time.Duration {
|
||||
rl.mutex.RLock()
|
||||
defer rl.mutex.RUnlock()
|
||||
|
||||
if entry, exists := rl.entries[key]; exists && len(entry.requests) > 0 {
|
||||
oldestRequest := entry.requests[0]
|
||||
return rl.window - rl.clock.Now().Sub(oldestRequest)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Cleanup() {
|
||||
rl.mutex.Lock()
|
||||
defer rl.mutex.Unlock()
|
||||
|
||||
now := rl.clock.Now()
|
||||
cutoff := now.Add(-rl.window)
|
||||
staleCutoff := now.Add(-rl.maxStaleAge)
|
||||
|
||||
for key, entry := range rl.entries {
|
||||
|
||||
var validRequests []time.Time
|
||||
for _, reqTime := range entry.requests {
|
||||
if reqTime.After(cutoff) {
|
||||
validRequests = append(validRequests, reqTime)
|
||||
}
|
||||
}
|
||||
entry.requests = validRequests
|
||||
|
||||
if len(entry.requests) == 0 && entry.lastAccess.Before(staleCutoff) {
|
||||
delete(rl.entries, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) StartCleanup() {
|
||||
rl.cleanupOnce.Do(func() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(rl.cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
rl.Cleanup()
|
||||
case <-rl.stopCleanup:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) StopCleanup() {
|
||||
rl.stopOnce.Do(func() {
|
||||
close(rl.stopCleanup)
|
||||
})
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) GetSize() int {
|
||||
rl.mutex.RLock()
|
||||
defer rl.mutex.RUnlock()
|
||||
return len(rl.entries)
|
||||
}
|
||||
|
||||
func GetSecureClientIP(r *http.Request) string {
|
||||
if TrustProxyHeaders {
|
||||
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
|
||||
ips := strings.Split(xff, ",")
|
||||
if len(ips) > 0 {
|
||||
ip := strings.TrimSpace(ips[0])
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
ip := strings.TrimSpace(xri)
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
|
||||
if net.ParseIP(r.RemoteAddr) != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
|
||||
return ip
|
||||
}
|
||||
|
||||
func GetKey(r *http.Request) string {
|
||||
ip := GetSecureClientIP(r)
|
||||
|
||||
if userID := GetUserIDFromContext(r.Context()); userID != 0 {
|
||||
return fmt.Sprintf("user:%d:ip:%s", userID, ip)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("ip:%s", ip)
|
||||
}
|
||||
|
||||
func RateLimitMiddleware(window time.Duration, limit int) func(http.Handler) http.Handler {
|
||||
limiter := getOrCreateLimiter(window, limit)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := GetKey(r)
|
||||
|
||||
if !limiter.Allow(key) {
|
||||
remainingTime := limiter.GetRemainingTime(key)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%.0f", remainingTime.Seconds()))
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
|
||||
response := map[string]any{
|
||||
"error": "Rate limit exceeded",
|
||||
"message": fmt.Sprintf("Too many requests. Please try again in %d seconds.", int(remainingTime.Seconds())),
|
||||
"retry_after": remainingTime.Seconds(),
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
jsonData = []byte(`{"error":"Rate limit exceeded"}`)
|
||||
}
|
||||
|
||||
w.Write(jsonData)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func AuthRateLimitMiddleware() func(http.Handler) http.Handler {
|
||||
return RateLimitMiddleware(1*time.Minute, 5)
|
||||
}
|
||||
|
||||
func AuthRateLimitMiddlewareWithLimit(limit int) func(http.Handler) http.Handler {
|
||||
return RateLimitMiddleware(1*time.Minute, limit)
|
||||
}
|
||||
|
||||
func GeneralRateLimitMiddleware() func(http.Handler) http.Handler {
|
||||
return RateLimitMiddleware(1*time.Minute, 100)
|
||||
}
|
||||
|
||||
func GeneralRateLimitMiddlewareWithLimit(limit int) func(http.Handler) http.Handler {
|
||||
return RateLimitMiddleware(1*time.Minute, limit)
|
||||
}
|
||||
|
||||
func HealthRateLimitMiddleware(limit int) func(http.Handler) http.Handler {
|
||||
return RateLimitMiddleware(1*time.Minute, limit)
|
||||
}
|
||||
|
||||
func MetricsRateLimitMiddleware(limit int) func(http.Handler) http.Handler {
|
||||
return RateLimitMiddleware(1*time.Minute, limit)
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
StopAllRateLimiters()
|
||||
}
|
||||
|
||||
type mockClock struct {
|
||||
mu sync.RWMutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newMockClock() *mockClock {
|
||||
return &mockClock{
|
||||
now: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *mockClock) Now() time.Time {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *mockClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
func (c *mockClock) Set(t time.Time) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = t
|
||||
}
|
||||
|
||||
func TestRateLimiterAllow(t *testing.T) {
|
||||
limiter := NewRateLimiter(1*time.Minute, 3)
|
||||
defer limiter.StopCleanup()
|
||||
|
||||
for i := range 3 {
|
||||
if !limiter.Allow("test-key") {
|
||||
t.Errorf("Request %d should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if limiter.Allow("test-key") {
|
||||
t.Error("4th request should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterWindow(t *testing.T) {
|
||||
clock := newMockClock()
|
||||
limiter := newRateLimiterWithClock(50*time.Millisecond, 2, clock)
|
||||
|
||||
limiter.Allow("test-key")
|
||||
limiter.Allow("test-key")
|
||||
|
||||
if limiter.Allow("test-key") {
|
||||
t.Error("Request should be rejected at limit")
|
||||
}
|
||||
|
||||
clock.Advance(75 * time.Millisecond)
|
||||
|
||||
if !limiter.Allow("test-key") {
|
||||
t.Error("Request should be allowed after window reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterDifferentKeys(t *testing.T) {
|
||||
limiter := NewRateLimiter(1*time.Minute, 2)
|
||||
defer limiter.StopCleanup()
|
||||
|
||||
limiter.Allow("key1")
|
||||
limiter.Allow("key1")
|
||||
limiter.Allow("key2")
|
||||
limiter.Allow("key2")
|
||||
|
||||
if limiter.Allow("key1") {
|
||||
t.Error("key1 should be at limit")
|
||||
}
|
||||
if limiter.Allow("key2") {
|
||||
t.Error("key2 should be at limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMiddleware(t *testing.T) {
|
||||
defer StopAllRateLimiters()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
middleware := RateLimitMiddleware(1*time.Minute, 2)
|
||||
server := middleware(handler)
|
||||
|
||||
for i := range 2 {
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Request %d should be allowed, got status %d", i+1, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("Expected status 429, got %d", recorder.Code)
|
||||
}
|
||||
|
||||
retryAfter := recorder.Header().Get("Retry-After")
|
||||
if retryAfter == "" {
|
||||
t.Error("Expected Retry-After header")
|
||||
}
|
||||
|
||||
retryAfterVal, err := time.ParseDuration(retryAfter + "s")
|
||||
if err != nil {
|
||||
t.Errorf("Retry-After header value is not a valid duration: %q", retryAfter)
|
||||
}
|
||||
if retryAfterVal.Seconds() < 50 || retryAfterVal.Seconds() > 60 {
|
||||
t.Errorf("Retry-After should be approximately 60 seconds, got %.0f", retryAfterVal.Seconds())
|
||||
}
|
||||
|
||||
var jsonResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
RetryAfter float64 `json:"retry_after"`
|
||||
}
|
||||
|
||||
body := recorder.Body.String()
|
||||
if err := json.Unmarshal([]byte(body), &jsonResponse); err != nil {
|
||||
t.Fatalf("Failed to decode JSON response: %v, body: %s", err, body)
|
||||
}
|
||||
|
||||
if jsonResponse.Error != "Rate limit exceeded" {
|
||||
t.Errorf("Expected error 'Rate limit exceeded', got %q", jsonResponse.Error)
|
||||
}
|
||||
|
||||
if !strings.Contains(jsonResponse.Message, "Too many requests") {
|
||||
t.Errorf("Expected message to contain 'Too many requests', got %q", jsonResponse.Message)
|
||||
}
|
||||
|
||||
expectedRetryAfter := int(retryAfterVal.Seconds())
|
||||
actualRetryAfter := int(jsonResponse.RetryAfter)
|
||||
diff := actualRetryAfter - expectedRetryAfter
|
||||
if diff < -1 || diff > 0 {
|
||||
t.Errorf("Expected retry_after %d in JSON (within 1s), got %.0f", expectedRetryAfter, jsonResponse.RetryAfter)
|
||||
}
|
||||
|
||||
if jsonResponse.RetryAfter <= 0 {
|
||||
t.Errorf("Expected retry_after to be positive, got %.0f", jsonResponse.RetryAfter)
|
||||
}
|
||||
|
||||
if !strings.Contains(jsonResponse.Message, "Too many requests. Please try again in") {
|
||||
t.Errorf("Expected message to contain 'Too many requests. Please try again in', got %q", jsonResponse.Message)
|
||||
}
|
||||
if !strings.Contains(jsonResponse.Message, "seconds.") {
|
||||
t.Errorf("Expected message to end with 'seconds.', got %q", jsonResponse.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRateLimitMiddleware(t *testing.T) {
|
||||
defer StopAllRateLimiters()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
middleware := AuthRateLimitMiddleware()
|
||||
server := middleware(handler)
|
||||
|
||||
for i := range 5 {
|
||||
request := httptest.NewRequest("POST", "/api/auth/login", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Request %d should be allowed, got status %d", i+1, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("POST", "/api/auth/login", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("Expected status 429, got %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneralRateLimitMiddleware(t *testing.T) {
|
||||
defer StopAllRateLimiters()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
middleware := GeneralRateLimitMiddleware()
|
||||
server := middleware(handler)
|
||||
|
||||
for i := range 10 {
|
||||
request := httptest.NewRequest("GET", "/api/posts", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Request %d should be allowed, got status %d", i+1, recorder.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetKey(t *testing.T) {
|
||||
|
||||
originalTrust := TrustProxyHeaders
|
||||
defer func() {
|
||||
TrustProxyHeaders = originalTrust
|
||||
}()
|
||||
|
||||
TrustProxyHeaders = false
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "192.168.1.1:12345"
|
||||
key := GetKey(request)
|
||||
expected := "ip:192.168.1.1"
|
||||
if key != expected {
|
||||
t.Errorf("Expected key %s, got %s", expected, key)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.1")
|
||||
key = GetKey(request)
|
||||
expected = "ip:127.0.0.1"
|
||||
if key != expected {
|
||||
t.Errorf("Expected key %s (proxy header ignored), got %s", expected, key)
|
||||
}
|
||||
|
||||
TrustProxyHeaders = true
|
||||
request = httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.1")
|
||||
key = GetKey(request)
|
||||
expected = "ip:203.0.113.1"
|
||||
if key != expected {
|
||||
t.Errorf("Expected key %s, got %s", expected, key)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.1, 198.51.100.1, 192.0.2.1")
|
||||
key = GetKey(request)
|
||||
expected = "ip:203.0.113.1"
|
||||
if key != expected {
|
||||
t.Errorf("Expected key %s (leftmost IP), got %s", expected, key)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
request.Header.Set("X-Real-IP", "198.51.100.1")
|
||||
key = GetKey(request)
|
||||
expected = "ip:198.51.100.1"
|
||||
if key != expected {
|
||||
t.Errorf("Expected key %s, got %s", expected, key)
|
||||
}
|
||||
|
||||
TrustProxyHeaders = originalTrust
|
||||
}
|
||||
|
||||
func TestGetSecureClientIP(t *testing.T) {
|
||||
|
||||
originalTrust := TrustProxyHeaders
|
||||
defer func() {
|
||||
TrustProxyHeaders = originalTrust
|
||||
}()
|
||||
|
||||
TrustProxyHeaders = false
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "192.168.1.1:12345"
|
||||
ip := GetSecureClientIP(request)
|
||||
if ip != "192.168.1.1" {
|
||||
t.Errorf("Expected IP 192.168.1.1, got %s", ip)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.1")
|
||||
ip = GetSecureClientIP(request)
|
||||
if ip != "127.0.0.1" {
|
||||
t.Errorf("Expected IP 127.0.0.1 (proxy header ignored), got %s", ip)
|
||||
}
|
||||
|
||||
TrustProxyHeaders = true
|
||||
request = httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.1")
|
||||
ip = GetSecureClientIP(request)
|
||||
if ip != "203.0.113.1" {
|
||||
t.Errorf("Expected IP 203.0.113.1, got %s", ip)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.1, 198.51.100.1")
|
||||
ip = GetSecureClientIP(request)
|
||||
if ip != "203.0.113.1" {
|
||||
t.Errorf("Expected IP 203.0.113.1 (leftmost), got %s", ip)
|
||||
}
|
||||
|
||||
TrustProxyHeaders = originalTrust
|
||||
}
|
||||
|
||||
func TestRateLimiterCleanup(t *testing.T) {
|
||||
clock := newMockClock()
|
||||
limiter := newRateLimiterWithClock(25*time.Millisecond, 2, clock)
|
||||
|
||||
limiter.Allow("test-key")
|
||||
limiter.Allow("test-key")
|
||||
|
||||
clock.Advance(50 * time.Millisecond)
|
||||
|
||||
limiter.Cleanup()
|
||||
|
||||
if !limiter.Allow("test-key") {
|
||||
t.Error("Request should be allowed after cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterConcurrent(t *testing.T) {
|
||||
limiter := NewRateLimiter(1*time.Minute, 10)
|
||||
defer limiter.StopCleanup()
|
||||
key := "concurrent-test"
|
||||
|
||||
results := make(chan bool, 20)
|
||||
for range 20 {
|
||||
go func() {
|
||||
allowed := limiter.Allow(key)
|
||||
results <- allowed
|
||||
}()
|
||||
}
|
||||
|
||||
allowedCount := 0
|
||||
rejectedCount := 0
|
||||
for range 20 {
|
||||
if <-results {
|
||||
allowedCount++
|
||||
} else {
|
||||
rejectedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if allowedCount != 10 {
|
||||
t.Errorf("Expected 10 allowed requests, got %d", allowedCount)
|
||||
}
|
||||
if rejectedCount != 10 {
|
||||
t.Errorf("Expected 10 rejected requests, got %d", rejectedCount)
|
||||
}
|
||||
|
||||
if limiter.Allow(key) {
|
||||
t.Error("Should be at limit after concurrent requests")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterMaxKeys(t *testing.T) {
|
||||
|
||||
limiter := NewRateLimiterWithConfig(1*time.Minute, 10, 5, 1*time.Minute, 2*time.Minute)
|
||||
defer limiter.StopCleanup()
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
key := fmt.Sprintf("key-%d", i)
|
||||
if !limiter.Allow(key) {
|
||||
t.Errorf("Key %s should be allowed", key)
|
||||
}
|
||||
}
|
||||
|
||||
if limiter.GetSize() != 5 {
|
||||
t.Errorf("Expected size 5, got %d", limiter.GetSize())
|
||||
}
|
||||
|
||||
limiter.Allow("key-1")
|
||||
limiter.Allow("key-2")
|
||||
limiter.Allow("key-3")
|
||||
limiter.Allow("key-4")
|
||||
|
||||
if !limiter.Allow("key-5") {
|
||||
t.Error("Key-5 should be allowed (after LRU eviction)")
|
||||
}
|
||||
|
||||
if limiter.GetSize() != 5 {
|
||||
t.Errorf("Expected size 5 after eviction, got %d", limiter.GetSize())
|
||||
}
|
||||
|
||||
if !limiter.Allow("key-0") {
|
||||
t.Error("Key-0 should be allowed (new entry after eviction)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterRegistry(t *testing.T) {
|
||||
defer StopAllRateLimiters()
|
||||
|
||||
middleware1 := RateLimitMiddleware(1*time.Minute, 100)
|
||||
middleware2 := RateLimitMiddleware(1*time.Minute, 100)
|
||||
middleware3 := RateLimitMiddleware(1*time.Minute, 50)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
server1 := middleware1(handler)
|
||||
server2 := middleware2(handler)
|
||||
server3 := middleware3(handler)
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
recorder := httptest.NewRecorder()
|
||||
server1.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Request %d to server1 should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
recorder2 := httptest.NewRecorder()
|
||||
server2.ServeHTTP(recorder2, request)
|
||||
if recorder2.Code != http.StatusOK {
|
||||
t.Errorf("Request %d to server2 should be allowed (shared limiter)", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
server1.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusTooManyRequests {
|
||||
t.Error("101st request to server1 should be rejected (shared limiter reached limit)")
|
||||
}
|
||||
|
||||
recorder2 := httptest.NewRecorder()
|
||||
server2.ServeHTTP(recorder2, request)
|
||||
if recorder2.Code != http.StatusTooManyRequests {
|
||||
t.Error("101st request to server2 should be rejected (shared limiter reached limit)")
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
recorder3 := httptest.NewRecorder()
|
||||
server3.ServeHTTP(recorder3, request)
|
||||
if recorder3.Code != http.StatusOK {
|
||||
t.Errorf("Request %d to server3 should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
recorder3 := httptest.NewRecorder()
|
||||
server3.ServeHTTP(recorder3, request)
|
||||
if recorder3.Code != http.StatusTooManyRequests {
|
||||
t.Error("51st request to server3 should be rejected (different limit)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAllRateLimiters(t *testing.T) {
|
||||
middleware1 := RateLimitMiddleware(1*time.Minute, 100)
|
||||
middleware2 := RateLimitMiddleware(1*time.Minute, 50)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
_ = middleware1(handler)
|
||||
_ = middleware2(handler)
|
||||
|
||||
StopAllRateLimiters()
|
||||
|
||||
middleware3 := RateLimitMiddleware(1*time.Minute, 100)
|
||||
server3 := middleware3(handler)
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server3.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Error("New limiter after StopAllRateLimiters should work")
|
||||
}
|
||||
|
||||
StopAllRateLimiters()
|
||||
}
|
||||
|
||||
func TestRateLimiterCleanupStaleEntries(t *testing.T) {
|
||||
clock := newMockClock()
|
||||
|
||||
limiter := &RateLimiter{
|
||||
entries: make(map[string]*keyEntry),
|
||||
window: 50 * time.Millisecond,
|
||||
limit: 10,
|
||||
maxKeys: 100,
|
||||
cleanupInterval: 100 * time.Millisecond,
|
||||
maxStaleAge: 150 * time.Millisecond,
|
||||
stopCleanup: make(chan struct{}),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
limiter.Allow("key1")
|
||||
if limiter.GetSize() != 1 {
|
||||
t.Errorf("Expected size 1, got %d", limiter.GetSize())
|
||||
}
|
||||
|
||||
clock.Advance(100 * time.Millisecond)
|
||||
|
||||
limiter.Cleanup()
|
||||
|
||||
clock.Advance(100 * time.Millisecond)
|
||||
limiter.Cleanup()
|
||||
|
||||
size := limiter.GetSize()
|
||||
if size != 0 {
|
||||
t.Errorf("Expected size 0 after cleanup, got %d", size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterGetSize(t *testing.T) {
|
||||
limiter := NewRateLimiter(1*time.Minute, 10)
|
||||
defer limiter.StopCleanup()
|
||||
|
||||
if limiter.GetSize() != 0 {
|
||||
t.Errorf("Expected initial size 0, got %d", limiter.GetSize())
|
||||
}
|
||||
|
||||
limiter.Allow("key1")
|
||||
if limiter.GetSize() != 1 {
|
||||
t.Errorf("Expected size 1, got %d", limiter.GetSize())
|
||||
}
|
||||
|
||||
limiter.Allow("key2")
|
||||
if limiter.GetSize() != 2 {
|
||||
t.Errorf("Expected size 2, got %d", limiter.GetSize())
|
||||
}
|
||||
|
||||
limiter.Allow("key1")
|
||||
if limiter.GetSize() != 2 {
|
||||
t.Errorf("Expected size 2, got %d", limiter.GetSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterLRUEviction(t *testing.T) {
|
||||
clock := newMockClock()
|
||||
|
||||
limiter := &RateLimiter{
|
||||
entries: make(map[string]*keyEntry),
|
||||
window: 1 * time.Minute,
|
||||
limit: 10,
|
||||
maxKeys: 3,
|
||||
cleanupInterval: 1 * time.Minute,
|
||||
maxStaleAge: 2 * time.Minute,
|
||||
stopCleanup: make(chan struct{}),
|
||||
clock: clock,
|
||||
}
|
||||
|
||||
limiter.Allow("key1")
|
||||
limiter.Allow("key2")
|
||||
limiter.Allow("key3")
|
||||
|
||||
if limiter.GetSize() != 3 {
|
||||
t.Errorf("Expected size 3, got %d", limiter.GetSize())
|
||||
}
|
||||
|
||||
clock.Advance(10 * time.Millisecond)
|
||||
limiter.Allow("key1")
|
||||
clock.Advance(10 * time.Millisecond)
|
||||
limiter.Allow("key2")
|
||||
|
||||
limiter.Allow("key4")
|
||||
|
||||
if limiter.GetSize() != 3 {
|
||||
t.Errorf("Expected size 3 after eviction, got %d", limiter.GetSize())
|
||||
}
|
||||
|
||||
if !limiter.Allow("key4") {
|
||||
t.Error("Key4 should exist and be allowed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func RequestSizeLimitMiddleware(maxSize int64) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body == nil || r.Body == http.NoBody {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
limitedBody := http.MaxBytesReader(w, r.Body, maxSize)
|
||||
r.Body = limitedBody
|
||||
defer func() {
|
||||
if err := limitedBody.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultRequestSizeLimitMiddleware() func(http.Handler) http.Handler {
|
||||
return RequestSizeLimitMiddleware(1024 * 1024)
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRequestSizeLimitMiddleware(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestSize int
|
||||
limitSize int64
|
||||
expectedStatus int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "request within limit",
|
||||
requestSize: 100,
|
||||
limitSize: 1000,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "request exactly at limit",
|
||||
requestSize: 1000,
|
||||
limitSize: 1000,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "request exceeds limit",
|
||||
requestSize: 1500,
|
||||
limitSize: 1000,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "request significantly exceeds limit",
|
||||
requestSize: 5000,
|
||||
limitSize: 1000,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "zero limit",
|
||||
requestSize: 100,
|
||||
limitSize: 0,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty request body",
|
||||
requestSize: 0,
|
||||
limitSize: 1000,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
|
||||
http.Error(w, "Request body too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Body size: " + strconv.Itoa(len(body))))
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(tt.limitSize)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
var body io.Reader
|
||||
if tt.requestSize > 0 {
|
||||
body = strings.NewReader(strings.Repeat("A", tt.requestSize))
|
||||
} else {
|
||||
body = http.NoBody
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", body)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, recorder.Code)
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected %d status for oversized request, got %d", http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
} else {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected %d status for valid request, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_NoBody(t *testing.T) {
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("No body"))
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(1000)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
request.Body = nil
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d for nil body, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_NoBodyHTTP(t *testing.T) {
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("No body"))
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(1000)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", http.NoBody)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d for http.NoBody, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_HandlerError(t *testing.T) {
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Handler error", http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(1000)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", strings.NewReader("small body"))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status %d for handler error, got %d", http.StatusInternalServerError, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_ReadBody(t *testing.T) {
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Read " + strconv.Itoa(len(body)) + " bytes"))
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(100)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", strings.NewReader("Hello, World!"))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
expectedBody := "Read 13 bytes"
|
||||
if !strings.Contains(recorder.Body.String(), expectedBody) {
|
||||
t.Errorf("Expected response to contain %q, got %q", expectedBody, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_PartialRead(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
buffer := make([]byte, 5)
|
||||
n, err := r.Body.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Read " + strconv.Itoa(n) + " bytes: " + string(buffer[:n])))
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(100)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", strings.NewReader("Hello, World!"))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
expectedBody := "Read 5 bytes: Hello"
|
||||
if !strings.Contains(recorder.Body.String(), expectedBody) {
|
||||
t.Errorf("Expected response to contain %q, got %q", expectedBody, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRequestSizeLimitMiddleware(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestSize int
|
||||
expectedStatus int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "request within 1MB limit",
|
||||
requestSize: 100 * 1024,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "request exactly 1MB",
|
||||
requestSize: 1024 * 1024,
|
||||
expectedStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "request exceeds 1MB",
|
||||
requestSize: 2 * 1024 * 1024,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Request body too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Body size: " + strconv.Itoa(len(body))))
|
||||
})
|
||||
|
||||
middleware := DefaultRequestSizeLimitMiddleware()
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", strings.NewReader(strings.Repeat("A", tt.requestSize)))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, recorder.Code)
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected %d status for oversized request, got %d", http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
} else {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected %d status for valid request, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_ConcurrentRequests(t *testing.T) {
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
_ = len(body)
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(1000)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := range 10 {
|
||||
go func(size int) {
|
||||
defer func() { done <- true }()
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", strings.NewReader(strings.Repeat("A", size)))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d for concurrent request, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
}(i * 100)
|
||||
}
|
||||
|
||||
for range 10 {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_LargeRequest(t *testing.T) {
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
|
||||
http.Error(w, "Request body too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
t.Error("Handler should not be called for oversized requests")
|
||||
_ = len(body)
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(100)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
largeBody := strings.NewReader(strings.Repeat("A", 10000))
|
||||
request := httptest.NewRequest("POST", "/test", largeBody)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d for large request, got %d", http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_EmptyBodyAfterLimit(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := make([]byte, 2000)
|
||||
n, err := r.Body.Read(body)
|
||||
|
||||
if err != nil && err != io.EOF {
|
||||
http.Error(w, "Body too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Read " + string(rune(n)) + " bytes"))
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(100)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", strings.NewReader(strings.Repeat("A", 500)))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest && recorder.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("Expected status %d or %d for oversized request, got %d", http.StatusBadRequest, http.StatusRequestEntityTooLarge, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_ChunkedBody(t *testing.T) {
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Read " + strconv.Itoa(len(body)) + " bytes"))
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(1000)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", strings.NewReader("Hello, World!"))
|
||||
request.TransferEncoding = []string{"chunked"}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d for chunked request, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_ContentLengthHeader(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Read " + strconv.Itoa(len(body)) + " bytes"))
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(1000)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
body := strings.NewReader("Hello, World!")
|
||||
request := httptest.NewRequest("POST", "/test", body)
|
||||
request.ContentLength = int64(len("Hello, World!"))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d for request with Content-Length, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_ZeroContentLength(t *testing.T) {
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Read " + strconv.Itoa(len(body)) + " bytes"))
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(1000)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", http.NoBody)
|
||||
request.ContentLength = 0
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d for zero Content-Length request, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSizeLimitMiddleware_InvalidContentLength(t *testing.T) {
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Read " + strconv.Itoa(len(body)) + " bytes"))
|
||||
})
|
||||
|
||||
middleware := RequestSizeLimitMiddleware(1000)
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
request := httptest.NewRequest("POST", "/test", strings.NewReader("Hello"))
|
||||
request.ContentLength = -1
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d for invalid Content-Length request, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const CSPNonceKey contextKey = "csp_nonce"
|
||||
|
||||
func GenerateCSPNonce() (string, error) {
|
||||
nonceBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate CSP nonce: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(nonceBytes), nil
|
||||
}
|
||||
|
||||
func GetCSPNonceFromContext(ctx context.Context) string {
|
||||
if nonce, ok := ctx.Value(CSPNonceKey).(string); ok {
|
||||
return nonce
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func SecurityHeadersMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
isSwaggerRoute := strings.HasPrefix(r.URL.Path, "/swagger")
|
||||
if isSwaggerRoute {
|
||||
csp := "default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"style-src-attr 'unsafe-inline'; " +
|
||||
"style-src-elem 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: https:; " +
|
||||
"font-src 'self' data:; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'"
|
||||
w.Header().Set("Content-Security-Policy", csp)
|
||||
} else {
|
||||
nonce, err := GenerateCSPNonce()
|
||||
if err != nil {
|
||||
|
||||
nonce = ""
|
||||
}
|
||||
|
||||
if nonce != "" {
|
||||
ctx := context.WithValue(r.Context(), CSPNonceKey, nonce)
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
|
||||
csp := "default-src 'self'; " +
|
||||
"img-src 'self' data: https:; " +
|
||||
"font-src 'self' data:; " +
|
||||
"connect-src 'self'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'"
|
||||
|
||||
if nonce != "" {
|
||||
csp = "script-src 'self' 'nonce-" + nonce + "'; " +
|
||||
"style-src 'self' 'nonce-" + nonce + "'; " + csp
|
||||
} else {
|
||||
|
||||
csp = "script-src 'self'; " +
|
||||
"style-src 'self'; " + csp
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Security-Policy", csp)
|
||||
}
|
||||
|
||||
permissionsPolicy := "geolocation=(), " +
|
||||
"microphone=(), " +
|
||||
"camera=(), " +
|
||||
"payment=(), " +
|
||||
"usb=(), " +
|
||||
"magnetometer=(), " +
|
||||
"gyroscope=(), " +
|
||||
"speaker=(), " +
|
||||
"vibrate=(), " +
|
||||
"fullscreen=(self), " +
|
||||
"sync-xhr=()"
|
||||
w.Header().Set("Permissions-Policy", permissionsPolicy)
|
||||
|
||||
w.Header().Set("Server", "")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func HSTSMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||
} else if TrustProxyHeaders {
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecurityHeadersMiddleware(t *testing.T) {
|
||||
handler := SecurityHeadersMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
expectedHeaders := map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Server": "",
|
||||
}
|
||||
|
||||
for header, expectedValue := range expectedHeaders {
|
||||
actualValue := recorder.Header().Get(header)
|
||||
if actualValue != expectedValue {
|
||||
t.Errorf("Expected %s: %s, got %s", header, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
|
||||
csp := recorder.Header().Get("Content-Security-Policy")
|
||||
if csp == "" {
|
||||
t.Error("Content-Security-Policy header should be present")
|
||||
}
|
||||
|
||||
expectedCSPDirectives := []string{
|
||||
"default-src 'self'",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
}
|
||||
|
||||
for _, directive := range expectedCSPDirectives {
|
||||
if !strings.Contains(csp, directive) {
|
||||
t.Errorf("Content-Security-Policy should contain directive: %s", directive)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(csp, "'unsafe-inline'") {
|
||||
t.Error("Content-Security-Policy should NOT contain 'unsafe-inline'")
|
||||
}
|
||||
if strings.Contains(csp, "'unsafe-eval'") {
|
||||
t.Error("Content-Security-Policy should NOT contain 'unsafe-eval'")
|
||||
}
|
||||
|
||||
if !strings.Contains(csp, "script-src") {
|
||||
t.Error("Content-Security-Policy should contain script-src directive")
|
||||
}
|
||||
if !strings.Contains(csp, "style-src") {
|
||||
t.Error("Content-Security-Policy should contain style-src directive")
|
||||
}
|
||||
|
||||
if strings.Contains(csp, "script-src 'self'") && !strings.Contains(csp, "nonce-") {
|
||||
|
||||
if !strings.Contains(csp, "script-src 'self'") {
|
||||
t.Error("Content-Security-Policy script-src should contain 'self'")
|
||||
}
|
||||
} else if !strings.Contains(csp, "nonce-") {
|
||||
t.Error("Content-Security-Policy should contain nonce-based script-src and style-src")
|
||||
}
|
||||
|
||||
permissionsPolicy := recorder.Header().Get("Permissions-Policy")
|
||||
if permissionsPolicy == "" {
|
||||
t.Error("Permissions-Policy header should be present")
|
||||
}
|
||||
|
||||
expectedPermissions := []string{
|
||||
"geolocation=()",
|
||||
"microphone=()",
|
||||
"camera=()",
|
||||
"payment=()",
|
||||
"usb=()",
|
||||
"magnetometer=()",
|
||||
"gyroscope=()",
|
||||
"speaker=()",
|
||||
"vibrate=()",
|
||||
"fullscreen=(self)",
|
||||
"sync-xhr=()",
|
||||
}
|
||||
|
||||
for _, permission := range expectedPermissions {
|
||||
if !strings.Contains(permissionsPolicy, permission) {
|
||||
t.Errorf("Permissions-Policy should contain permission: %s", permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHSTSMiddleware_HTTPS(t *testing.T) {
|
||||
handler := HSTSMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
request.TLS = &tls.ConnectionState{}
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
hsts := recorder.Header().Get("Strict-Transport-Security")
|
||||
expectedHSTS := "max-age=31536000; includeSubDomains; preload"
|
||||
|
||||
if hsts != expectedHSTS {
|
||||
t.Errorf("Expected HSTS header: %s, got: %s", expectedHSTS, hsts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHSTSMiddleware_HTTP(t *testing.T) {
|
||||
handler := HSTSMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
hsts := recorder.Header().Get("Strict-Transport-Security")
|
||||
if hsts != "" {
|
||||
t.Errorf("Expected no HSTS header for HTTP request, got: %s", hsts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersMiddleware_ResponsePassthrough(t *testing.T) {
|
||||
expectedBody := "test response body"
|
||||
expectedStatus := http.StatusCreated
|
||||
|
||||
handler := SecurityHeadersMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(expectedStatus)
|
||||
w.Write([]byte(expectedBody))
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", expectedStatus, recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Body.String() != expectedBody {
|
||||
t.Errorf("Expected body %s, got %s", expectedBody, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersMiddleware_MultipleRequests(t *testing.T) {
|
||||
handler := SecurityHeadersMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
for i := range 3 {
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
requiredHeaders := []string{
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"X-XSS-Protection",
|
||||
"Referrer-Policy",
|
||||
"Content-Security-Policy",
|
||||
"Permissions-Policy",
|
||||
}
|
||||
|
||||
for _, header := range requiredHeaders {
|
||||
if recorder.Header().Get(header) == "" {
|
||||
t.Errorf("Request %d: Expected header %s to be present", i+1, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersMiddleware_ContentSecurityPolicyFormat(t *testing.T) {
|
||||
handler := SecurityHeadersMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
csp := recorder.Header().Get("Content-Security-Policy")
|
||||
|
||||
if strings.Contains(csp, " ") {
|
||||
t.Error("Content-Security-Policy should not contain double spaces")
|
||||
}
|
||||
|
||||
directives := strings.Split(csp, "; ")
|
||||
if len(directives) < 8 {
|
||||
t.Errorf("Content-Security-Policy should have at least 8 directives, got %d", len(directives))
|
||||
}
|
||||
|
||||
if strings.HasSuffix(csp, ";") {
|
||||
t.Error("Content-Security-Policy should not end with semicolon")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersMiddleware_PermissionsPolicyFormat(t *testing.T) {
|
||||
handler := SecurityHeadersMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
permissionsPolicy := recorder.Header().Get("Permissions-Policy")
|
||||
|
||||
if strings.Contains(permissionsPolicy, " ") {
|
||||
t.Error("Permissions-Policy should not contain double spaces")
|
||||
}
|
||||
|
||||
permissions := strings.Split(permissionsPolicy, ", ")
|
||||
if len(permissions) < 10 {
|
||||
t.Errorf("Permissions-Policy should have at least 10 permissions, got %d", len(permissions))
|
||||
}
|
||||
|
||||
if strings.HasSuffix(permissionsPolicy, ",") {
|
||||
t.Error("Permissions-Policy should not end with comma")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSPNonceGeneration(t *testing.T) {
|
||||
|
||||
nonce1, err := GenerateCSPNonce()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate CSP nonce: %v", err)
|
||||
}
|
||||
|
||||
if nonce1 == "" {
|
||||
t.Error("Generated nonce should not be empty")
|
||||
}
|
||||
|
||||
if len(nonce1) < 16 {
|
||||
t.Errorf("Generated nonce should be at least 16 characters, got %d", len(nonce1))
|
||||
}
|
||||
|
||||
nonce2, err := GenerateCSPNonce()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate second CSP nonce: %v", err)
|
||||
}
|
||||
|
||||
if nonce1 == nonce2 {
|
||||
t.Error("Generated nonces should be unique")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSPNonceInContext(t *testing.T) {
|
||||
var capturedNonce string
|
||||
|
||||
handler := SecurityHeadersMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedNonce = GetCSPNonceFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
request := httptest.NewRequest("GET", "/test", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if capturedNonce == "" {
|
||||
t.Error("CSP nonce should be available in request context")
|
||||
}
|
||||
|
||||
csp := recorder.Header().Get("Content-Security-Policy")
|
||||
if !strings.Contains(csp, "nonce-"+capturedNonce) {
|
||||
t.Errorf("CSP header should contain nonce from context. CSP: %s, Nonce: %s", csp, capturedNonce)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SecurityLogger struct {
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewSecurityLogger() *SecurityLogger {
|
||||
return &SecurityLogger{
|
||||
logger: log.New(os.Stdout, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
||||
}
|
||||
}
|
||||
|
||||
type SecurityEvent struct {
|
||||
Type string
|
||||
IP string
|
||||
UserAgent string
|
||||
Path string
|
||||
Method string
|
||||
UserID uint
|
||||
Details string
|
||||
Timestamp time.Time
|
||||
Severity string
|
||||
}
|
||||
|
||||
func (sl *SecurityLogger) LogSecurityEvent(event SecurityEvent) {
|
||||
sl.logger.Printf("[%s] %s - %s %s %s - UserID: %d - %s - %s",
|
||||
event.Severity,
|
||||
event.IP,
|
||||
event.Method,
|
||||
event.Path,
|
||||
event.UserAgent,
|
||||
event.UserID,
|
||||
event.Type,
|
||||
event.Details,
|
||||
)
|
||||
}
|
||||
|
||||
func SecurityLoggingMiddleware(logger *SecurityLogger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
rw := &securityResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
userID := GetUserIDFromContext(r.Context())
|
||||
ip := getClientIP(r)
|
||||
|
||||
event := SecurityEvent{
|
||||
IP: ip,
|
||||
UserAgent: r.UserAgent(),
|
||||
Path: r.URL.Path,
|
||||
Method: r.Method,
|
||||
UserID: userID,
|
||||
Timestamp: start,
|
||||
}
|
||||
|
||||
switch {
|
||||
case rw.statusCode >= 400 && rw.statusCode < 500:
|
||||
event.Type = "Client Error"
|
||||
event.Severity = "WARN"
|
||||
event.Details = "Client error response"
|
||||
case rw.statusCode >= 500:
|
||||
event.Type = "Server Error"
|
||||
event.Severity = "ERROR"
|
||||
event.Details = "Server error response"
|
||||
case strings.HasPrefix(r.URL.Path, "/api/auth/"):
|
||||
event.Type = "Authentication"
|
||||
event.Severity = "INFO"
|
||||
event.Details = "Authentication endpoint accessed"
|
||||
case strings.HasPrefix(r.URL.Path, "/api/posts/") && r.Method == "POST":
|
||||
event.Type = "Post Creation"
|
||||
event.Severity = "INFO"
|
||||
event.Details = "Post creation attempt"
|
||||
case strings.HasPrefix(r.URL.Path, "/api/posts/") && (r.Method == "PUT" || r.Method == "DELETE"):
|
||||
event.Type = "Post Modification"
|
||||
event.Severity = "INFO"
|
||||
event.Details = "Post modification attempt"
|
||||
default:
|
||||
event.Type = "API Access"
|
||||
event.Severity = "INFO"
|
||||
event.Details = "API endpoint accessed"
|
||||
}
|
||||
|
||||
logger.LogSecurityEvent(event)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func SuspiciousActivityMiddleware(logger *SecurityLogger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := getClientIP(r)
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
suspicious := false
|
||||
details := ""
|
||||
|
||||
if containsSQLInjection(r.URL.RawQuery) || containsSQLInjection(r.URL.Path) {
|
||||
suspicious = true
|
||||
details = "Potential SQL injection attempt"
|
||||
}
|
||||
|
||||
if containsXSS(r.URL.RawQuery) || containsXSS(r.URL.Path) {
|
||||
suspicious = true
|
||||
details = "Potential XSS attempt"
|
||||
}
|
||||
|
||||
if isSuspiciousUserAgent(userAgent) {
|
||||
suspicious = true
|
||||
details = "Suspicious user agent"
|
||||
}
|
||||
|
||||
if isRapidRequest(ip) {
|
||||
suspicious = true
|
||||
details = "Rapid request pattern"
|
||||
}
|
||||
|
||||
if suspicious {
|
||||
event := SecurityEvent{
|
||||
Type: "Suspicious Activity",
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
Path: r.URL.Path,
|
||||
Method: r.Method,
|
||||
Details: details,
|
||||
Timestamp: time.Now(),
|
||||
Severity: "WARN",
|
||||
}
|
||||
logger.LogSecurityEvent(event)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type securityResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *securityResponseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func getClientIP(r *http.Request) string {
|
||||
return GetSecureClientIP(r)
|
||||
}
|
||||
|
||||
func containsSQLInjection(input string) bool {
|
||||
sqlPatterns := []string{
|
||||
"' OR '1'='1",
|
||||
"'; DROP TABLE",
|
||||
"UNION SELECT",
|
||||
"INSERT INTO",
|
||||
"DELETE FROM",
|
||||
"UPDATE SET",
|
||||
}
|
||||
|
||||
input = strings.ToUpper(input)
|
||||
for _, pattern := range sqlPatterns {
|
||||
if strings.Contains(input, strings.ToUpper(pattern)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsXSS(input string) bool {
|
||||
xssPatterns := []string{
|
||||
"<script>",
|
||||
"javascript:",
|
||||
"onload=",
|
||||
"onerror=",
|
||||
"onclick=",
|
||||
"<iframe>",
|
||||
"<img src=",
|
||||
}
|
||||
|
||||
input = strings.ToLower(input)
|
||||
for _, pattern := range xssPatterns {
|
||||
if strings.Contains(input, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSuspiciousUserAgent(userAgent string) bool {
|
||||
suspiciousPatterns := []string{
|
||||
"sqlmap",
|
||||
"nikto",
|
||||
"nmap",
|
||||
"masscan",
|
||||
"zap",
|
||||
"burp",
|
||||
"w3af",
|
||||
"havij",
|
||||
"acunetix",
|
||||
"nessus",
|
||||
}
|
||||
|
||||
userAgent = strings.ToLower(userAgent)
|
||||
for _, pattern := range suspiciousPatterns {
|
||||
if strings.Contains(userAgent, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var requestCounts = make(map[string]int)
|
||||
var lastReset = time.Now()
|
||||
|
||||
func isRapidRequest(ip string) bool {
|
||||
now := time.Now()
|
||||
|
||||
if now.Sub(lastReset) > time.Minute {
|
||||
requestCounts = make(map[string]int)
|
||||
lastReset = now
|
||||
}
|
||||
|
||||
requestCounts[ip]++
|
||||
|
||||
return requestCounts[ip] > 100
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user