Password complexity: custom validator requiring uppercase, lowercase, digit (min 8 chars)
Token expiry: 90-day token lifetime with refresh endpoint (60-90 day renewal window)
Health check: /api/health/ now pings Postgres + Redis, returns 503 on failure
Audit logging: async audit_log table for auth events (login, register, delete, etc.)
Circuit breaker: APNs/FCM push sends wrapped with 5-failure threshold, 30s recovery
FK indexes: 27 missing foreign key indexes across all tables (migration 017)
CSP header: default-src 'none'; frame-ancestors 'none'
Gzip compression: level 5 with media endpoint skipper
Prometheus metrics: /metrics endpoint using existing monitoring service
External timeouts: 15s push, 30s SMTP, context timeouts on all external calls
Migrations: 016 (token created_at), 017 (FK indexes), 018 (audit_log)
Tests: circuit breaker (15), audit service (8), token refresh (7), health (4),
middleware expiry (5), validator (new)
177 lines
4.5 KiB
Go
177 lines
4.5 KiB
Go
package services
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
"github.com/treytartt/honeydue-api/internal/testutil"
|
|
)
|
|
|
|
func TestAuditService_LogEvent_WritesToDatabase(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
svc := NewAuditService(db)
|
|
defer svc.Stop()
|
|
|
|
// Create a fake Echo context
|
|
e := echo.New()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login/", nil)
|
|
req.Header.Set("User-Agent", "TestAgent/1.0")
|
|
req.RemoteAddr = "192.168.1.1:12345"
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
|
|
userID := uint(42)
|
|
svc.LogEvent(c, &userID, AuditEventLogin, map[string]interface{}{
|
|
"method": "password",
|
|
})
|
|
|
|
// Stop flushes the channel
|
|
svc.Stop()
|
|
|
|
var entries []models.AuditLog
|
|
err := db.Find(&entries).Error
|
|
require.NoError(t, err)
|
|
require.Len(t, entries, 1)
|
|
|
|
entry := entries[0]
|
|
assert.Equal(t, uint(42), *entry.UserID)
|
|
assert.Equal(t, AuditEventLogin, entry.EventType)
|
|
assert.Equal(t, "TestAgent/1.0", entry.UserAgent)
|
|
assert.NotEmpty(t, entry.IPAddress)
|
|
assert.Equal(t, "password", entry.Details["method"])
|
|
assert.False(t, entry.CreatedAt.IsZero())
|
|
}
|
|
|
|
func TestAuditService_LogEvent_NilUserID(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
svc := NewAuditService(db)
|
|
defer svc.Stop()
|
|
|
|
e := echo.New()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login/", nil)
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
|
|
svc.LogEvent(c, nil, AuditEventLoginFailed, map[string]interface{}{
|
|
"identifier": "unknown@test.com",
|
|
})
|
|
|
|
svc.Stop()
|
|
|
|
var entries []models.AuditLog
|
|
err := db.Find(&entries).Error
|
|
require.NoError(t, err)
|
|
require.Len(t, entries, 1)
|
|
|
|
assert.Nil(t, entries[0].UserID)
|
|
assert.Equal(t, AuditEventLoginFailed, entries[0].EventType)
|
|
}
|
|
|
|
func TestAuditService_LogEvent_NilContext(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
svc := NewAuditService(db)
|
|
defer svc.Stop()
|
|
|
|
userID := uint(1)
|
|
svc.LogEvent(nil, &userID, AuditEventLogout, nil)
|
|
|
|
svc.Stop()
|
|
|
|
var entries []models.AuditLog
|
|
err := db.Find(&entries).Error
|
|
require.NoError(t, err)
|
|
require.Len(t, entries, 1)
|
|
|
|
assert.Equal(t, AuditEventLogout, entries[0].EventType)
|
|
assert.Empty(t, entries[0].IPAddress)
|
|
assert.Empty(t, entries[0].UserAgent)
|
|
}
|
|
|
|
func TestAuditService_LogEvent_MultipleEvents(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
svc := NewAuditService(db)
|
|
defer svc.Stop()
|
|
|
|
e := echo.New()
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
|
|
userID := uint(10)
|
|
svc.LogEvent(c, &userID, AuditEventRegister, nil)
|
|
svc.LogEvent(c, &userID, AuditEventLogin, nil)
|
|
svc.LogEvent(c, &userID, AuditEventLogout, nil)
|
|
|
|
svc.Stop()
|
|
|
|
var count int64
|
|
err := db.Model(&models.AuditLog{}).Count(&count).Error
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(3), count)
|
|
}
|
|
|
|
func TestAuditService_EventTypeConstants(t *testing.T) {
|
|
// Verify all event constants have expected values
|
|
assert.Equal(t, "auth.login", AuditEventLogin)
|
|
assert.Equal(t, "auth.login_failed", AuditEventLoginFailed)
|
|
assert.Equal(t, "auth.register", AuditEventRegister)
|
|
assert.Equal(t, "auth.logout", AuditEventLogout)
|
|
assert.Equal(t, "auth.password_reset", AuditEventPasswordReset)
|
|
assert.Equal(t, "auth.password_changed", AuditEventPasswordChanged)
|
|
assert.Equal(t, "auth.account_deleted", AuditEventAccountDeleted)
|
|
}
|
|
|
|
func TestAuditService_Stop_FlushesRemainingEntries(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
svc := NewAuditService(db)
|
|
|
|
e := echo.New()
|
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
|
|
// Send many events quickly
|
|
for i := 0; i < 50; i++ {
|
|
uid := uint(i)
|
|
svc.LogEvent(c, &uid, AuditEventLogin, nil)
|
|
}
|
|
|
|
// Stop should block until all entries are written
|
|
svc.Stop()
|
|
|
|
var count int64
|
|
err := db.Model(&models.AuditLog{}).Count(&count).Error
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(50), count)
|
|
}
|
|
|
|
func TestAuditLog_TableName(t *testing.T) {
|
|
log := models.AuditLog{}
|
|
assert.Equal(t, "audit_log", log.TableName())
|
|
}
|
|
|
|
func TestAuditLog_JSONMap_NilHandling(t *testing.T) {
|
|
db := testutil.SetupTestDB(t)
|
|
|
|
// Create entry with nil details
|
|
entry := &models.AuditLog{
|
|
EventType: "test",
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
err := db.Create(entry).Error
|
|
require.NoError(t, err)
|
|
|
|
// Read it back
|
|
var found models.AuditLog
|
|
err = db.First(&found, entry.ID).Error
|
|
require.NoError(t, err)
|
|
assert.Nil(t, found.Details)
|
|
}
|