To gitea and beyond, let's go(-yco)

This commit is contained in:
2025-11-10 19:12:09 +01:00
parent 8f6133392d
commit 71a031342b
245 changed files with 83994 additions and 0 deletions
+318
View File
@@ -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
}
+997
View File
@@ -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)
}
}
})
}
}
+77
View File
@@ -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()
}
+169
View File
@@ -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)
}
+253
View File
@@ -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")
}
})
}
+156
View File
@@ -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)
}
}
+88
View File
@@ -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"
)
+603
View File
@@ -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")
}
})
}
+190
View File
@@ -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
}
+325
View File
@@ -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")
}
}
+175
View File
@@ -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)
}
+368
View File
@@ -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")
}
+69
View File
@@ -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
}
+183
View File
@@ -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)
}
}
})
}
+76
View File
@@ -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
}
+187
View File
@@ -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)
}
}
})
}
+39
View File
@@ -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
}
+149
View File
@@ -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)
}
}
})
}
+271
View File
@@ -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
+258
View File
@@ -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)
}
})
}
+216
View File
@@ -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)
}
}
})
}
+507
View File
@@ -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")
}
})
}
+364
View File
@@ -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)
}
})
}
+327
View File
@@ -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
}
+375
View File
@@ -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)
}
})
}
+108
View File
@@ -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
}
}
})
})
}
+254
View File
@@ -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")
}
})
}
+167
View File
@@ -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")
}
})
}
+602
View File
@@ -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")
}
})
}
+874
View File
@@ -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)
}
}
})
}
+125
View File
@@ -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)
}
})
}
+179
View File
@@ -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)
}
})
})
}
+192
View File
@@ -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)
}
})
}
+266
View File
@@ -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)
}
})
})
}
+611
View File
@@ -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")
}
})
}
+246
View File
@@ -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")
}
})
}
+89
View File
@@ -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
}
+226
View File
@@ -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
+298
View File
@@ -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
}
+187
View File
@@ -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
}
+238
View File
@@ -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) {
}
+280
View File
@@ -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")
}
}
+825
View File
@@ -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
+292
View File
@@ -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
+146
View File
@@ -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
+464
View File
@@ -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)
}
+711
View File
@@ -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)
}
})
}
+21
View File
@@ -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
}
+412
View File
@@ -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)
}
})
}
}
+195
View File
@@ -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)
}
+362
View File
@@ -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)
})
}
}
+293
View File
@@ -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)
}
+482
View File
@@ -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 &copy, nil
}
return nil, gorm.ErrRecordNotFound
}
postRepo.UpdateFn = func(post *database.Post) error {
copy := *post
posts[post.ID] = &copy
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] = &copy
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(), &registerPayload); 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, "&lt;script&gt;") {
t.Errorf("Expected script tags to be HTML-escaped (&lt;script&gt;), 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, "&#39;") {
t.Errorf("Expected single quotes to be HTML-escaped (&#39;), 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)")
}
})
}
+358
View File
@@ -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)
}
})
}
+81
View File
@@ -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
}
+141
View File
@@ -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)
}
}
+205
View File
@@ -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)
})
}
}
+666
View File
@@ -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)
}
}
})
}
+174
View File
@@ -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/",
},
}
}
+670
View File
@@ -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])
}
}
}
+140
View File
@@ -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)
}
+514
View File
@@ -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)
}
}
})
}
}
+114
View File
@@ -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"
}
+219
View File
@@ -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)
}
}
+277
View File
@@ -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
}
+422
View File
@@ -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)
}
}
+53
View File
@@ -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)
}
+57
View File
@@ -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)
}
}
+393
View File
@@ -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)
}
+601
View File
@@ -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")
}
}
+30
View File
@@ -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)
}
+501
View File
@@ -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)
}
}
+116
View File
@@ -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)
}
}
+237
View File
@@ -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