feat(auth): replace hand-rolled auth with Ory Kratos — phase 2 backend
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled

Delegates all credential management (login, register, password reset,
email verification, social sign-in) to Ory Kratos. The Go API now acts
as a resource server: the new KratosAuth middleware validates sessions
against the Kratos whoami endpoint, writes the local User mirror into
Echo context, and all existing domain handlers continue working
unchanged. Hand-rolled token auth, AuthToken model, apple_auth/
google_auth services, and the auth refresh flow are removed. Tests are
updated to use the fake-token middleware pattern so existing integration
assertions require no rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-05-18 17:55:56 -05:00
parent b66151ddd9
commit 81578f6e27
36 changed files with 927 additions and 7002 deletions
+21
View File
@@ -190,6 +190,27 @@ func shouldSkipSpecRoute(path string) bool {
if strings.HasPrefix(path, "/uploads/") || strings.HasPrefix(path, "/media/") {
return true
}
// Auth routes delegated to Ory Kratos (phase 2 auth refactor).
// These endpoints are no longer served by the Go API; the spec is retained
// as documentation of the Kratos-facing contract.
kratosRoutes := map[string]bool{
"/auth/login/": true,
"/auth/register/": true,
"/auth/logout/": true,
"/auth/refresh/": true,
"/auth/forgot-password/": true,
"/auth/verify-reset-code/": true,
"/auth/reset-password/": true,
"/auth/verify-email/": true,
"/auth/resend-verification/": true,
"/auth/apple-sign-in/": true,
"/auth/google-sign-in/": true,
}
if kratosRoutes[path] {
return true
}
return false
}
+98 -282
View File
@@ -6,9 +6,11 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -17,6 +19,7 @@ import (
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/handlers"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/testutil"
@@ -105,11 +108,40 @@ type TestApp struct {
TaskRepo *repositories.TaskRepository
ContractorRepo *repositories.ContractorRepository
AuthService *services.AuthService
// tokenStore maps fake token strings to users for the test-auth middleware.
tokenStore map[string]*models.User
tokenStoreMu sync.RWMutex
}
// fakeAuthMiddleware replaces the real Kratos middleware in integration tests.
// It looks up the "Authorization: Token <tok>" value in app.tokenStore.
func (app *TestApp) fakeAuthMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ah := c.Request().Header.Get("Authorization")
if ah == "" {
return apperrors.Unauthorized("error.not_authenticated")
}
tok := ah
if len(ah) > 6 && ah[:6] == "Token " {
tok = ah[6:]
} else if len(ah) > 7 && ah[:7] == "Bearer " {
tok = ah[7:]
}
app.tokenStoreMu.RLock()
user, ok := app.tokenStore[tok]
app.tokenStoreMu.RUnlock()
if !ok {
return apperrors.Unauthorized("error.not_authenticated")
}
c.Set("auth_user", user)
c.Set("auth_token", tok)
return next(c)
}
}
}
func setupIntegrationTest(t *testing.T) *TestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
@@ -122,10 +154,7 @@ func setupIntegrationTest(t *testing.T) *TestApp {
// Create config
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key-for-integration-tests",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
SecretKey: "test-secret-key-for-integration-tests",
},
}
@@ -141,28 +170,33 @@ func setupIntegrationTest(t *testing.T) *TestApp {
taskHandler := handlers.NewTaskHandler(taskService, nil)
contractorHandler := handlers.NewContractorHandler(contractorService)
// Create router with real middleware
e := echo.New()
app := &TestApp{
DB: db,
Router: echo.New(),
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
tokenStore: make(map[string]*models.User),
}
e := app.Router
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
// Add timezone middleware globally so X-Timezone header is processed
// Timezone middleware processes X-Timezone header
e.Use(middleware.TimezoneMiddleware())
// Public routes
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes - use AuthMiddleware without Redis cache for testing
authMiddleware := middleware.NewAuthMiddleware(db, nil)
// Protected routes — guarded by the fake token middleware
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
api.Use(app.fakeAuthMiddleware())
{
api.GET("/auth/me", authHandler.CurrentUser)
api.POST("/auth/logout", authHandler.Logout)
residences := api.Group("/residences")
{
@@ -216,19 +250,7 @@ func setupIntegrationTest(t *testing.T) *TestApp {
api.GET("/contractors/by-residence/:residence_id", contractorHandler.ListContractorsByResidence)
}
return &TestApp{
DB: db,
Router: e,
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
}
return app
}
// Helper to make authenticated requests
@@ -251,156 +273,16 @@ func (app *TestApp) makeAuthenticatedRequest(t *testing.T, method, path string,
return w
}
// Helper to register and login a user, returns token
func (app *TestApp) registerAndLogin(t *testing.T, username, email, password string) string {
// Register
registerBody := map[string]string{
"username": username,
"email": email,
"password": password,
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code)
// Login
loginBody := map[string]string{
"username": username,
"password": password,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code)
var loginResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
return loginResp["token"].(string)
}
// ============ Authentication Flow Tests ============
func TestIntegration_AuthenticationFlow(t *testing.T) {
app := setupIntegrationTest(t)
// 1. Register a new user
registerBody := map[string]string{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePass123!",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
assert.Equal(t, http.StatusCreated, w.Code)
var registerResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &registerResp)
require.NoError(t, err)
assert.NotEmpty(t, registerResp["token"])
assert.NotNil(t, registerResp["user"])
// 2. Login with the same credentials
loginBody := map[string]string{
"username": "testuser",
"password": "SecurePass123!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
assert.Equal(t, http.StatusOK, w.Code)
var loginResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
assert.NotEmpty(t, token)
// 3. Get current user with token
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
var meResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &meResp)
require.NoError(t, err)
assert.Equal(t, "testuser", meResp["username"])
assert.Equal(t, "test@example.com", meResp["email"])
// 4. Access protected route without token should fail
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, "")
assert.Equal(t, http.StatusUnauthorized, w.Code)
// 5. Access protected route with invalid token should fail
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, "invalid-token")
assert.Equal(t, http.StatusUnauthorized, w.Code)
// 6. Logout
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/logout", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestIntegration_RegistrationValidation(t *testing.T) {
app := setupIntegrationTest(t)
tests := []struct {
name string
body map[string]string
expectedStatus int
}{
{
name: "missing username",
body: map[string]string{"email": "test@example.com", "password": "pass123"},
expectedStatus: http.StatusBadRequest,
},
{
name: "missing email",
body: map[string]string{"username": "testuser", "password": "pass123"},
expectedStatus: http.StatusBadRequest,
},
{
name: "missing password",
body: map[string]string{"username": "testuser", "email": "test@example.com"},
expectedStatus: http.StatusBadRequest,
},
{
name: "invalid email",
body: map[string]string{"username": "testuser", "email": "invalid", "password": "pass123"},
expectedStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", tt.body, "")
assert.Equal(t, tt.expectedStatus, w.Code)
})
}
}
func TestIntegration_DuplicateRegistration(t *testing.T) {
app := setupIntegrationTest(t)
// Register first user (password must be >= 8 chars)
registerBody := map[string]string{
"username": "testuser",
"email": "test@example.com",
"password": "Password123",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
assert.Equal(t, http.StatusCreated, w.Code)
// Try to register with same username - returns 409 (Conflict)
registerBody2 := map[string]string{
"username": "testuser",
"email": "different@example.com",
"password": "Password123",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody2, "")
assert.Equal(t, http.StatusConflict, w.Code)
// Try to register with same email - returns 409 (Conflict)
registerBody3 := map[string]string{
"username": "differentuser",
"email": "test@example.com",
"password": "Password123",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody3, "")
assert.Equal(t, http.StatusConflict, w.Code)
// registerAndLogin creates a user directly in the DB and returns a synthetic token
// that the fake auth middleware will accept. No HTTP register/login endpoints are called.
func (app *TestApp) registerAndLogin(t *testing.T, username, email, _ string) string {
t.Helper()
user := testutil.CreateTestUser(t, app.DB, username, email, "")
tok := uuid.NewString()
app.tokenStoreMu.Lock()
app.tokenStore[tok] = user
app.tokenStoreMu.Unlock()
return tok
}
// ============ Residence Flow Tests ============
@@ -827,48 +709,16 @@ func TestIntegration_ResponseStructure(t *testing.T) {
func TestIntegration_ComprehensiveE2E(t *testing.T) {
app := setupIntegrationTest(t)
// ============ Phase 1: Authentication ============
t.Log("Phase 1: Testing authentication flow")
// ============ Phase 1: User Setup ============
t.Log("Phase 1: Setting up test user")
// Register new user
registerBody := map[string]string{
"username": "e2e_testuser",
"email": "e2e@example.com",
"password": "SecurePass123!",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code, "Registration should succeed")
var registerResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &registerResp)
require.NoError(t, err)
assert.NotEmpty(t, registerResp["token"], "Registration should return token")
assert.NotNil(t, registerResp["user"], "Registration should return user")
// Verify login with same credentials
loginBody := map[string]string{
"username": "e2e_testuser",
"password": "SecurePass123!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code, "Login should succeed")
var loginResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
assert.NotEmpty(t, token, "Login should return token")
token := app.registerAndLogin(t, "e2e_testuser", "e2e@example.com", "")
// Verify authenticated access
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
w := app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
require.Equal(t, http.StatusOK, w.Code, "Should access protected route with valid token")
var meResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &meResp)
assert.Equal(t, "e2e_testuser", meResp["username"])
assert.Equal(t, "e2e@example.com", meResp["email"])
t.Log("✓ Authentication flow verified")
t.Log("✓ User setup verified")
// ============ Phase 2: Create 5 Residences ============
t.Log("Phase 2: Creating 5 residences")
@@ -1244,29 +1094,9 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
t.Logf("✓ All %d visible tasks verified in correct columns by ID", expectedVisibleTasks)
// ============ Phase 9: Create User B ============
t.Log("Phase 9: Creating User B and verifying login")
t.Log("Phase 9: Creating User B")
// Register User B
registerBodyB := map[string]string{
"username": "e2e_userb",
"email": "e2e_userb@example.com",
"password": "SecurePass456!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBodyB, "")
require.Equal(t, http.StatusCreated, w.Code, "User B registration should succeed")
// Login as User B
loginBodyB := map[string]string{
"username": "e2e_userb",
"password": "SecurePass456!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBodyB, "")
require.Equal(t, http.StatusOK, w.Code, "User B login should succeed")
var loginRespB map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &loginRespB)
tokenB := loginRespB["token"].(string)
assert.NotEmpty(t, tokenB, "User B should have a token")
tokenB := app.registerAndLogin(t, "e2e_userb", "e2e_userb@example.com", "")
// Verify User B can access their own profile
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, tokenB)
@@ -1592,8 +1422,6 @@ func formatID(id float64) string {
// setupContractorTest sets up a test environment including contractor routes
func setupContractorTest(t *testing.T) *TestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
@@ -1606,10 +1434,7 @@ func setupContractorTest(t *testing.T) *TestApp {
// Create config
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key-for-integration-tests",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
SecretKey: "test-secret-key-for-integration-tests",
},
}
@@ -1625,29 +1450,32 @@ func setupContractorTest(t *testing.T) *TestApp {
taskHandler := handlers.NewTaskHandler(taskService, nil)
contractorHandler := handlers.NewContractorHandler(contractorService)
// Create router with real middleware
e := echo.New()
app := &TestApp{
DB: db,
Router: echo.New(),
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
tokenStore: make(map[string]*models.User),
}
e := app.Router
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
// Add timezone middleware globally so X-Timezone header is processed
// Timezone middleware
e.Use(middleware.TimezoneMiddleware())
// Public routes
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
api.Use(app.fakeAuthMiddleware())
{
api.GET("/auth/me", authHandler.CurrentUser)
api.POST("/auth/logout", authHandler.Logout)
residences := api.Group("/residences")
{
residences.GET("", residenceHandler.ListResidences)
@@ -1680,19 +1508,7 @@ func setupContractorTest(t *testing.T) *TestApp {
}
}
return &TestApp{
DB: db,
Router: e,
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
}
return app
}
// ============ Test 1: Recurring Task Lifecycle ============
@@ -2045,12 +1861,12 @@ func TestIntegration_MultiUserSharing(t *testing.T) {
// Phase 9: Remove User B from residence 3
t.Log("Phase 9: Remove User B from residence 3")
// Get User B's ID
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
var userBInfo map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userBInfo)
userBID := uint(userBInfo["id"].(float64))
// Get User B's ID from the token store
app.tokenStoreMu.RLock()
userBModel := app.tokenStore[tokenB]
app.tokenStoreMu.RUnlock()
require.NotNil(t, userBModel, "User B should be in token store")
userBID := userBModel.ID
// Remove User B from residence 3
w = app.makeAuthenticatedRequest(t, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d", residenceIDs[2], userBID), nil, tokenA)
@@ -6,9 +6,11 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -35,6 +37,48 @@ type SecurityTestApp struct {
Router *echo.Echo
SubscriptionService *services.SubscriptionService
SubscriptionRepo *repositories.SubscriptionRepository
tokenStore map[string]*models.User
tokenStoreMu sync.RWMutex
}
// fakeAuthMiddleware returns an Echo middleware that authenticates requests using
// the in-process tokenStore instead of calling the real Kratos session endpoint.
func (app *SecurityTestApp) fakeAuthMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ah := c.Request().Header.Get("Authorization")
if ah == "" {
return apperrors.Unauthorized("error.not_authenticated")
}
tok := ah
if len(ah) > 6 && ah[:6] == "Token " {
tok = ah[6:]
} else if len(ah) > 7 && ah[:7] == "Bearer " {
tok = ah[7:]
}
app.tokenStoreMu.RLock()
user, ok := app.tokenStore[tok]
app.tokenStoreMu.RUnlock()
if !ok {
return apperrors.Unauthorized("error.not_authenticated")
}
c.Set("auth_user", user)
c.Set("auth_token", tok)
return next(c)
}
}
}
// registerAndLoginSec creates a user directly in the DB and returns a fake token
// that the fakeAuthMiddleware will accept. No HTTP register/login calls are made.
func (app *SecurityTestApp) registerAndLoginSec(t *testing.T, username, email, _ string) (string, uint) {
t.Helper()
user := testutil.CreateTestUser(t, app.DB, username, email, "")
tok := uuid.NewString()
app.tokenStoreMu.Lock()
app.tokenStore[tok] = user
app.tokenStoreMu.Unlock()
return tok, user.ID
}
func setupSecurityTest(t *testing.T) *SecurityTestApp {
@@ -78,27 +122,25 @@ func setupSecurityTest(t *testing.T) *SecurityTestApp {
notificationHandler := handlers.NewNotificationHandler(notificationService)
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, nil)
// Create router with real middleware
app := &SecurityTestApp{
DB: db,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
tokenStore: make(map[string]*models.User),
}
// Create router with fake auth middleware
e := echo.New()
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
e.Use(middleware.TimezoneMiddleware())
// Public routes
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
api.Use(app.fakeAuthMiddleware())
{
api.GET("/auth/me", authHandler.CurrentUser)
api.POST("/auth/logout", authHandler.Logout)
residences := api.Group("/residences")
{
@@ -146,42 +188,8 @@ func setupSecurityTest(t *testing.T) *SecurityTestApp {
}
}
return &SecurityTestApp{
DB: db,
Router: e,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
}
}
// registerAndLoginSec registers and logs in a user, returns token and user ID.
func (app *SecurityTestApp) registerAndLoginSec(t *testing.T, username, email, password string) (string, uint) {
// Register
registerBody := map[string]string{
"username": username,
"email": email,
"password": password,
}
w := app.makeAuthReq(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code, "Registration should succeed for %s", username)
// Login
loginBody := map[string]string{
"username": username,
"password": password,
}
w = app.makeAuthReq(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code, "Login should succeed for %s", username)
var loginResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
userMap := loginResp["user"].(map[string]interface{})
userID := uint(userMap["id"].(float64))
return token, userID
app.Router = e
return app
}
// makeAuthReq creates and sends an HTTP request through the router.
@@ -6,12 +6,15 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/config"
@@ -22,7 +25,6 @@ import (
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/testutil"
"github.com/treytartt/honeydue-api/internal/validator"
"gorm.io/gorm"
)
// SubscriptionTestApp holds components for subscription integration testing
@@ -31,11 +33,51 @@ type SubscriptionTestApp struct {
Router *echo.Echo
SubscriptionService *services.SubscriptionService
SubscriptionRepo *repositories.SubscriptionRepository
tokenStore map[string]*models.User
tokenStoreMu sync.RWMutex
}
// fakeAuthMiddleware returns an Echo middleware that authenticates requests using
// the in-process tokenStore instead of calling the real Kratos session endpoint.
func (app *SubscriptionTestApp) fakeAuthMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ah := c.Request().Header.Get("Authorization")
if ah == "" {
return apperrors.Unauthorized("error.not_authenticated")
}
tok := ah
if len(ah) > 6 && ah[:6] == "Token " {
tok = ah[6:]
} else if len(ah) > 7 && ah[:7] == "Bearer " {
tok = ah[7:]
}
app.tokenStoreMu.RLock()
user, ok := app.tokenStore[tok]
app.tokenStoreMu.RUnlock()
if !ok {
return apperrors.Unauthorized("error.not_authenticated")
}
c.Set("auth_user", user)
c.Set("auth_token", tok)
return next(c)
}
}
}
// registerAndLogin creates a user directly in the DB and returns a fake token
// and user ID. No HTTP register/login calls are made.
func (app *SubscriptionTestApp) registerAndLogin(t *testing.T, username, email, _ string) (string, uint) {
t.Helper()
user := testutil.CreateTestUser(t, app.DB, username, email, "")
tok := uuid.NewString()
app.tokenStoreMu.Lock()
app.tokenStore[tok] = user
app.tokenStoreMu.Unlock()
return tok, user.ID
}
func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
@@ -67,22 +109,23 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil, true)
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, nil)
// Create router
app := &SubscriptionTestApp{
DB: db,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
tokenStore: make(map[string]*models.User),
}
// Create router with fake auth middleware
e := echo.New()
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
// Public routes
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
e.Use(middleware.TimezoneMiddleware())
// Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
api.Use(app.fakeAuthMiddleware())
{
api.GET("/auth/me", authHandler.CurrentUser)
@@ -98,12 +141,8 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
}
}
return &SubscriptionTestApp{
DB: db,
Router: e,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
}
app.Router = e
return app
}
// Helper to make authenticated requests
@@ -129,36 +168,6 @@ func (app *SubscriptionTestApp) makeAuthenticatedRequest(t *testing.T, method, p
return w
}
// Helper to register and login a user, returns token and user ID
func (app *SubscriptionTestApp) registerAndLogin(t *testing.T, username, email, password string) (string, uint) {
// Register
registerBody := map[string]string{
"username": username,
"email": email,
"password": password,
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code)
// Login
loginBody := map[string]string{
"username": username,
"password": password,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code)
var loginResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
userMap := loginResp["user"].(map[string]interface{})
userID := uint(userMap["id"].(float64))
return token, userID
}
// TestIntegration_IsFreeBypassesLimitations tests that users with IsFree=true
// see limitations_enabled=false regardless of global settings
func TestIntegration_IsFreeBypassesLimitations(t *testing.T) {