Production hardening: security, resilience, observability, and compliance

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)
This commit is contained in:
Trey T
2026-03-26 14:05:28 -05:00
parent 4abc57535e
commit b679f28e55
30 changed files with 2077 additions and 47 deletions

View File

@@ -0,0 +1,176 @@
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)
}