Files
honeyDueAPI/internal/integration/subscription_is_free_test.go
T
Trey t 81578f6e27
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
feat(auth): replace hand-rolled auth with Ory Kratos — phase 2 backend
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>
2026-05-18 17:55:56 -05:00

391 lines
13 KiB
Go

package integration
import (
"bytes"
"context"
"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"
"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"
"github.com/treytartt/honeydue-api/internal/validator"
)
// SubscriptionTestApp holds components for subscription integration testing
type SubscriptionTestApp struct {
DB *gorm.DB
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 {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
// Create repositories
userRepo := repositories.NewUserRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
taskRepo := repositories.NewTaskRepository(db)
contractorRepo := repositories.NewContractorRepository(db)
documentRepo := repositories.NewDocumentRepository(db)
subscriptionRepo := repositories.NewSubscriptionRepository(db)
// Create config
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key-for-subscription-tests",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
},
}
// Create services
authService := services.NewAuthService(userRepo, cfg)
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
// Create handlers
authHandler := handlers.NewAuthHandler(authService, nil, nil)
residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil, true)
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, nil)
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
e.Use(middleware.TimezoneMiddleware())
// Protected routes
api := e.Group("/api")
api.Use(app.fakeAuthMiddleware())
{
api.GET("/auth/me", authHandler.CurrentUser)
residences := api.Group("/residences")
{
residences.POST("", residenceHandler.CreateResidence)
}
subscription := api.Group("/subscription")
{
subscription.GET("/", subscriptionHandler.GetSubscription)
subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus)
}
}
app.Router = e
return app
}
// Helper to make authenticated requests
func (app *SubscriptionTestApp) makeAuthenticatedRequest(t *testing.T, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
var reqBody []byte
var err error
if body != nil {
reqBody, err = json.Marshal(body)
require.NoError(t, err)
}
req := httptest.NewRequest(method, path, nil)
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewBuffer(reqBody))
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Token "+token)
}
w := httptest.NewRecorder()
app.Router.ServeHTTP(w, req)
return w
}
// TestIntegration_IsFreeBypassesLimitations tests that users with IsFree=true
// see limitations_enabled=false regardless of global settings
func TestIntegration_IsFreeBypassesLimitations(t *testing.T) {
app := setupSubscriptionTest(t)
// Register and login a user
token, userID := app.registerAndLogin(t, "freeuser", "free@test.com", "Password123")
// Enable global limitations - first delete any existing, then create with enabled
app.DB.Where("1=1").Delete(&models.SubscriptionSettings{})
settings := &models.SubscriptionSettings{EnableLimitations: true}
err := app.DB.Create(settings).Error
require.NoError(t, err)
// Verify limitations are enabled globally
var verifySettings models.SubscriptionSettings
app.DB.First(&verifySettings)
require.True(t, verifySettings.EnableLimitations, "Global limitations should be enabled")
// ========== Test 1: Normal user sees limitations_enabled=true ==========
w := app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
var statusResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &statusResp)
require.NoError(t, err)
assert.True(t, statusResp["limitations_enabled"].(bool),
"Normal user should see limitations_enabled=true when global setting is enabled")
// ========== Test 2: Set IsFree=true for user ==========
// Get user's subscription
sub, err := app.SubscriptionRepo.GetOrCreate(userID)
require.NoError(t, err)
// Set IsFree=true
sub.IsFree = true
err = app.SubscriptionRepo.Update(sub)
require.NoError(t, err)
// ========== Test 3: User with IsFree=true sees limitations_enabled=false ==========
w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
err = json.Unmarshal(w.Body.Bytes(), &statusResp)
require.NoError(t, err)
assert.False(t, statusResp["limitations_enabled"].(bool),
"User with IsFree=true should see limitations_enabled=false regardless of global setting")
}
// TestIntegration_IsFreeBypassesCheckLimit tests that IsFree users can create
// resources beyond their tier limits
func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) {
app := setupSubscriptionTest(t)
// Register and login a user
_, userID := app.registerAndLogin(t, "limituser", "limit@test.com", "Password123")
// Enable global limitations
settings := &models.SubscriptionSettings{EnableLimitations: true}
app.DB.Where("1=1").Delete(&models.SubscriptionSettings{})
app.DB.Create(settings)
// Set free tier limit to 1 property
one := 1
freeLimits := &models.TierLimits{
Tier: models.TierFree,
PropertiesLimit: &one,
}
app.DB.Where("tier = ?", models.TierFree).Delete(&models.TierLimits{})
app.DB.Create(freeLimits)
// Get user's subscription (should be free tier)
sub, err := app.SubscriptionRepo.GetOrCreate(userID)
require.NoError(t, err)
require.Equal(t, models.TierFree, sub.Tier)
// ========== Test 1: Normal free user hits limit ==========
// First property should succeed
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "properties")
assert.NoError(t, err, "First property should be allowed")
// Create a property to use up the limit
residence := &models.Residence{
Name: "Test Property",
OwnerID: userID,
}
app.DB.Create(residence)
// Second property should fail
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "properties")
assert.Error(t, err, "Second property should be blocked for normal free user")
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, http.StatusForbidden, appErr.Code)
assert.Equal(t, "error.properties_limit_exceeded", appErr.MessageKey)
// ========== Test 2: Set IsFree=true ==========
sub.IsFree = true
err = app.SubscriptionRepo.Update(sub)
require.NoError(t, err)
// ========== Test 3: IsFree user bypasses limit ==========
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "properties")
assert.NoError(t, err, "IsFree user should bypass property limits")
// Should also bypass other limits
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "tasks")
assert.NoError(t, err, "IsFree user should bypass task limits")
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "contractors")
assert.NoError(t, err, "IsFree user should bypass contractor limits")
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "documents")
assert.NoError(t, err, "IsFree user should bypass document limits")
}
// TestIntegration_IsFreeIndependentOfTier tests that IsFree works regardless of
// the user's subscription tier
func TestIntegration_IsFreeIndependentOfTier(t *testing.T) {
app := setupSubscriptionTest(t)
// Register and login a user
token, userID := app.registerAndLogin(t, "tieruser", "tier@test.com", "Password123")
// Enable global limitations
settings := &models.SubscriptionSettings{EnableLimitations: true}
app.DB.Where("1=1").Delete(&models.SubscriptionSettings{})
app.DB.Create(settings)
// Get user's subscription
sub, err := app.SubscriptionRepo.GetOrCreate(userID)
require.NoError(t, err)
// ========== Test with Free tier + IsFree ==========
sub.Tier = models.TierFree
sub.IsFree = true
err = app.SubscriptionRepo.Update(sub)
require.NoError(t, err)
w := app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
var statusResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &statusResp)
assert.False(t, statusResp["limitations_enabled"].(bool),
"Free tier user with IsFree should see limitations_enabled=false")
// ========== Test with Pro tier + IsFree ==========
sub.Tier = models.TierPro
sub.IsFree = true
err = app.SubscriptionRepo.Update(sub)
require.NoError(t, err)
w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &statusResp)
assert.False(t, statusResp["limitations_enabled"].(bool),
"Pro tier user with IsFree should see limitations_enabled=false")
// ========== Test disabling IsFree restores normal behavior ==========
sub.Tier = models.TierFree
sub.IsFree = false
err = app.SubscriptionRepo.Update(sub)
require.NoError(t, err)
w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &statusResp)
assert.True(t, statusResp["limitations_enabled"].(bool),
"Free tier user without IsFree should see limitations_enabled=true")
}
// TestIntegration_IsFreeWhenGlobalLimitationsDisabled tests that IsFree has no
// effect when global limitations are already disabled
func TestIntegration_IsFreeWhenGlobalLimitationsDisabled(t *testing.T) {
app := setupSubscriptionTest(t)
// Register and login a user
token, userID := app.registerAndLogin(t, "globaluser", "global@test.com", "Password123")
// Disable global limitations
settings := &models.SubscriptionSettings{EnableLimitations: false}
app.DB.Where("1=1").Delete(&models.SubscriptionSettings{})
app.DB.Create(settings)
// Get user's subscription
sub, err := app.SubscriptionRepo.GetOrCreate(userID)
require.NoError(t, err)
// ========== Test 1: Without IsFree, limitations are disabled ==========
sub.IsFree = false
app.SubscriptionRepo.Update(sub)
w := app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
var statusResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &statusResp)
assert.False(t, statusResp["limitations_enabled"].(bool),
"When global limitations are disabled, limitations_enabled should be false")
// ========== Test 2: With IsFree, limitations are still disabled ==========
sub.IsFree = true
app.SubscriptionRepo.Update(sub)
w = app.makeAuthenticatedRequest(t, "GET", "/api/subscription/status/", nil, token)
require.Equal(t, http.StatusOK, w.Code)
json.Unmarshal(w.Body.Bytes(), &statusResp)
assert.False(t, statusResp["limitations_enabled"].(bool),
"With IsFree and global limitations disabled, limitations_enabled should be false")
// Both cases result in the same outcome - no limitations
err = app.SubscriptionService.CheckLimit(context.Background(), userID, "properties")
assert.NoError(t, err, "Should bypass limits when global limitations are disabled")
}