Files
gochat/backend/tests/e2e/auth_e2e_test.go
T
2026-08-18 00:55:45 +08:00

526 lines
15 KiB
Go

package e2e
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/pkg/crypto"
)
// AuthE2ETestSuite tests the full authentication flow end-to-end:
// Register → Login → Refresh → Logout
// Reference: Chatwoot spec/controllers/api/v1/accounts_controller_spec.rb
// and spec/controllers/devise_token_auth/sessions_controller_spec.rb
type AuthE2ETestSuite struct {
E2ETestSuite
}
func (s *AuthE2ETestSuite) TestRegistrationEndpointIsNotExposed() {
account := s.CreateTestAccount("Registration Org")
registerPayload := map[string]interface{}{
"name": "New Test User",
"email": "register@test.com",
"password": "SecurePass123!",
"account_id": account.ID,
}
body, _ := json.Marshal(registerPayload)
resp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/register", s.ServerURL()),
"application/json",
bytes.NewBuffer(body),
)
s.Require().NoError(err)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusNotFound, resp.StatusCode)
var count int64
s.Require().NoError(s.DB().Model(&model.User{}).Where("email = ?", "register@test.com").Count(&count).Error)
assert.Zero(s.T(), count, "an unregistered route must not create a user")
}
func (s *AuthE2ETestSuite) TestRegistrationEndpointCannotMutateExistingUser() {
account := s.CreateTestAccount("Dup Org")
s.CreateTestUser("dup@test.com", "Existing", "hashedpass", "agent", account.ID)
registerPayload := map[string]interface{}{
"name": "Duplicate User",
"email": "dup@test.com",
"password": "AnotherPass!",
"account_id": account.ID,
}
body, _ := json.Marshal(registerPayload)
resp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/register", s.ServerURL()),
"application/json",
bytes.NewBuffer(body),
)
s.Require().NoError(err)
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusNotFound, resp.StatusCode)
var count int64
s.Require().NoError(s.DB().Model(&model.User{}).Where("email = ?", "dup@test.com").Count(&count).Error)
assert.Equal(s.T(), int64(1), count, "an unregistered route must not mutate existing users")
}
func (s *AuthE2ETestSuite) TestLoginWithValidCredentials() {
account := s.CreateTestAccount("Login Org")
hashedPassword, err := crypto.HashPassword("ValidPass123!")
assert.NoError(s.T(), err)
s.CreateTestUser("login@test.com", "Login User", hashedPassword, "agent", account.ID)
loginPayload := map[string]interface{}{
"email": "login@test.com",
"password": "ValidPass123!",
}
body, _ := json.Marshal(loginPayload)
resp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/login", s.ServerURL()),
"application/json",
bytes.NewBuffer(body),
)
if err != nil {
s.T().Logf("Login endpoint not available: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.NotNil(s.T(), result["access_token"], "Login should return access token")
assert.NotNil(s.T(), result["refresh_token"], "Login should return refresh token")
}
}
func (s *AuthE2ETestSuite) TestLoginWithInvalidPassword() {
account := s.CreateTestAccount("Invalid Org")
hashedPassword, _ := crypto.HashPassword("CorrectPass!")
s.CreateTestUser("invalid@test.com", "Invalid User", hashedPassword, "agent", account.ID)
loginPayload := map[string]interface{}{
"email": "invalid@test.com",
"password": "WrongPassword!",
}
body, _ := json.Marshal(loginPayload)
resp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/login", s.ServerURL()),
"application/json",
bytes.NewBuffer(body),
)
if err != nil {
return
}
defer resp.Body.Close()
assert.True(s.T(),
resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest,
"Invalid password should be rejected",
)
}
func (s *AuthE2ETestSuite) TestLoginNonexistentUser() {
loginPayload := map[string]interface{}{
"email": "nonexistent@test.com",
"password": "SomePass!",
}
body, _ := json.Marshal(loginPayload)
resp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/login", s.ServerURL()),
"application/json",
bytes.NewBuffer(body),
)
if err != nil {
return
}
defer resp.Body.Close()
assert.True(s.T(),
resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusNotFound,
"Nonexistent user login should be rejected",
)
}
func (s *AuthE2ETestSuite) TestRefreshTokenFlow() {
account := s.CreateTestAccount("Refresh Org")
hashedPassword, _ := crypto.HashPassword("RefreshPass!")
s.CreateTestUser("refresh@test.com", "Refresh User", hashedPassword, "agent", account.ID)
// First login
loginPayload := map[string]interface{}{
"email": "refresh@test.com",
"password": "RefreshPass!",
}
loginBody, _ := json.Marshal(loginPayload)
loginResp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/login", s.ServerURL()),
"application/json",
bytes.NewBuffer(loginBody),
)
if err != nil {
return
}
loginResp.Body.Close()
if loginResp.StatusCode == http.StatusOK {
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
refreshToken, _ := loginResult["refresh_token"].(string)
if refreshToken != "" {
// Use refresh token to get new access token
refreshPayload := map[string]interface{}{
"refresh_token": refreshToken,
}
refreshBody, _ := json.Marshal(refreshPayload)
refreshResp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/refresh", s.ServerURL()),
"application/json",
bytes.NewBuffer(refreshBody),
)
if err != nil {
return
}
defer refreshResp.Body.Close()
assert.Equal(s.T(), http.StatusOK, refreshResp.StatusCode, "Refresh should succeed")
}
}
}
func (s *AuthE2ETestSuite) TestLogoutFlow() {
account := s.CreateTestAccount("Logout Org")
hashedPassword, _ := crypto.HashPassword("LogoutPass!")
s.CreateTestUser("logout@test.com", "Logout User", hashedPassword, "agent", account.ID)
// Login first
loginPayload := map[string]interface{}{
"email": "logout@test.com",
"password": "LogoutPass!",
}
loginBody, _ := json.Marshal(loginPayload)
loginResp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/login", s.ServerURL()),
"application/json",
bytes.NewBuffer(loginBody),
)
if err != nil {
return
}
if loginResp.StatusCode == http.StatusOK {
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
loginResp.Body.Close()
accessToken, _ := loginResult["access_token"].(string)
// Logout with access token
client := &http.Client{}
logoutReq, _ := http.NewRequest(
http.MethodDelete,
fmt.Sprintf("%s/api/v1/auth/logout", s.ServerURL()),
nil,
)
logoutReq.Header.Set("Authorization", "Bearer "+accessToken)
logoutResp, err := client.Do(logoutReq)
if err != nil {
return
}
defer logoutResp.Body.Close()
assert.Equal(s.T(), http.StatusOK, logoutResp.StatusCode, "Logout should succeed")
} else {
loginResp.Body.Close()
}
}
func (s *AuthE2ETestSuite) TestAccessProtectedEndpointWithoutToken() {
account := s.CreateTestAccount("Protected Org")
client := &http.Client{}
req, _ := http.NewRequest(
http.MethodGet,
fmt.Sprintf("%s/api/v1/accounts/%d", s.ServerURL(), account.ID),
nil,
)
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode,
"Protected endpoint should require authentication",
)
}
func (s *AuthE2ETestSuite) TestAccessProtectedEndpointWithValidToken() {
account := s.CreateTestAccount("Token Org")
hashedPassword, _ := crypto.HashPassword("TokenPass!")
s.CreateTestUser("token@test.com", "Token User", hashedPassword, "agent", account.ID)
// Login to get token
loginPayload := map[string]interface{}{
"email": "token@test.com",
"password": "TokenPass!",
}
loginBody, _ := json.Marshal(loginPayload)
loginResp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/login", s.ServerURL()),
"application/json",
bytes.NewBuffer(loginBody),
)
if err != nil {
return
}
if loginResp.StatusCode == http.StatusOK {
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
loginResp.Body.Close()
accessToken, _ := loginResult["access_token"].(string)
// Access protected endpoint
client := &http.Client{}
req, _ := http.NewRequest(
http.MethodGet,
fmt.Sprintf("%s/api/v1/accounts/%d", s.ServerURL(), account.ID),
nil,
)
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
assert.Equal(s.T(), http.StatusOK, resp.StatusCode,
"Valid token should access protected endpoint",
)
} else {
loginResp.Body.Close()
}
}
func (s *AuthE2ETestSuite) TestJWTTokenExpiry() {
// Test that expired tokens are rejected
cfg := crypto.JWTConfig{
Secret: []byte("e2e-test-secret-key"),
ExpiryHours: -1, // Already expired
RefreshExpiryHours: 24,
}
expiredToken, err := crypto.GenerateToken(cfg, 1, "expired@test.com", "agent")
assert.NoError(s.T(), err)
_, err = crypto.ValidateToken(cfg, expiredToken)
assert.Error(s.T(), err, "Expired token should be rejected")
}
func (s *AuthE2ETestSuite) TestJWTTokenValidation() {
cfg := crypto.JWTConfig{
Secret: []byte("e2e-test-secret-key"),
ExpiryHours: 1,
RefreshExpiryHours: 24,
}
// Generate valid token
token, err := crypto.GenerateToken(cfg, 1, "valid@test.com", "agent")
assert.NoError(s.T(), err, "Token generation should succeed")
// Validate token
claims, err := crypto.ValidateToken(cfg, token)
assert.NoError(s.T(), err, "Token validation should succeed")
assert.Equal(s.T(), uint(1), uint(claims["user_id"].(float64)))
assert.Equal(s.T(), "valid@test.com", claims["email"])
assert.Equal(s.T(), "agent", claims["type"])
}
func (s *AuthE2ETestSuite) TestInvalidJWTTokenRejected() {
cfg := crypto.JWTConfig{
Secret: []byte("e2e-test-secret-key"),
ExpiryHours: 1,
RefreshExpiryHours: 24,
}
// Token signed with different secret
wrongCfg := crypto.JWTConfig{
Secret: []byte("wrong-secret-key"),
ExpiryHours: 1,
RefreshExpiryHours: 24,
}
wrongToken, err := crypto.GenerateToken(wrongCfg, 1, "wrong@test.com", "agent")
assert.NoError(s.T(), err)
_, err = crypto.ValidateToken(cfg, wrongToken)
assert.Error(s.T(), err, "Token with wrong secret should be rejected")
}
func (s *AuthE2ETestSuite) TestPasswordHashing() {
password := "TestPassword123!"
hash, err := crypto.HashPassword(password)
assert.NoError(s.T(), err, "Password hashing should succeed")
assert.NotEqual(s.T(), password, hash, "Hash should differ from password")
assert.True(s.T(), crypto.CheckPassword(password, hash), "Password should match hash")
assert.False(s.T(), crypto.CheckPassword("WrongPassword", hash), "Wrong password should not match")
}
func (s *AuthE2ETestSuite) TestFullAuthLifecycle() {
// Complete flow: Register → Login → Access → Refresh → Logout
account := s.CreateTestAccount("Lifecycle Auth Org")
hashedPassword, _ := crypto.HashPassword("LifecyclePass!")
s.CreateTestUser("lifecycle@test.com", "Lifecycle User", hashedPassword, "agent", account.ID)
// Step 1: Login
loginPayload := map[string]interface{}{
"email": "lifecycle@test.com",
"password": "LifecyclePass!",
}
loginBody, _ := json.Marshal(loginPayload)
loginResp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/login", s.ServerURL()),
"application/json",
bytes.NewBuffer(loginBody),
)
if err != nil {
s.T().Logf("Auth lifecycle: login endpoint not available: %v", err)
return
}
if loginResp.StatusCode == http.StatusOK {
var loginResult map[string]interface{}
json.NewDecoder(loginResp.Body).Decode(&loginResult)
loginResp.Body.Close()
accessToken, _ := loginResult["access_token"].(string)
assert.NotEmpty(s.T(), accessToken, "Login should return access token")
// Step 2: Access protected endpoint
client := &http.Client{}
req, _ := http.NewRequest(http.MethodGet,
fmt.Sprintf("%s/api/v1/accounts/%d/conversations", s.ServerURL(), account.ID),
nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
accessResp, err := client.Do(req)
if err == nil {
assert.True(s.T(), accessResp.StatusCode == http.StatusOK || accessResp.StatusCode == http.StatusForbidden,
"Authenticated request should get valid response")
accessResp.Body.Close()
}
// Step 3: Logout
logoutReq, _ := http.NewRequest(http.MethodDelete,
fmt.Sprintf("%s/api/v1/auth/logout", s.ServerURL()), nil)
logoutReq.Header.Set("Authorization", "Bearer "+accessToken)
logoutResp, err := client.Do(logoutReq)
if err == nil {
assert.Equal(s.T(), http.StatusOK, logoutResp.StatusCode)
logoutResp.Body.Close()
}
} else {
loginResp.Body.Close()
}
}
func (s *AuthE2ETestSuite) TestMultipleUsersCanLoginIndependently() {
account := s.CreateTestAccount("Multi Org")
hp1, _ := crypto.HashPassword("Pass1!")
hp2, _ := crypto.HashPassword("Pass2!")
s.CreateTestUser("multi1@test.com", "User 1", hp1, "agent", account.ID)
s.CreateTestUser("multi2@test.com", "User 2", hp2, "agent", account.ID)
// Both users should be able to generate valid JWT tokens independently
cfg := crypto.JWTConfig{
Secret: []byte("e2e-test-secret-key"),
ExpiryHours: 1,
RefreshExpiryHours: 24,
}
token1, err := crypto.GenerateToken(cfg, 1, "multi1@test.com", "agent")
assert.NoError(s.T(), err)
token2, err := crypto.GenerateToken(cfg, 2, "multi2@test.com", "agent")
assert.NoError(s.T(), err)
assert.NotEqual(s.T(), token1, token2, "Different users should get different tokens")
}
func (s *AuthE2ETestSuite) TestInactiveUserLoginRejected() {
account := s.CreateTestAccount("Inactive Org")
hp, _ := crypto.HashPassword("InactivePass!")
user := &model.User{
AccountID: account.ID,
Name: "Inactive User",
Email: "inactive@test.com",
Password: hp,
Role: "agent",
Active: false, // Inactive
}
s.DB().Create(user)
// Attempt login
loginPayload := map[string]interface{}{
"email": "inactive@test.com",
"password": "InactivePass!",
}
body, _ := json.Marshal(loginPayload)
resp, err := http.Post(
fmt.Sprintf("%s/api/v1/auth/login", s.ServerURL()),
"application/json",
bytes.NewBuffer(body),
)
if err != nil {
return
}
defer resp.Body.Close()
// Inactive user should be rejected (or if endpoint not available, verify at DB level)
if resp.StatusCode != 0 {
assert.True(s.T(),
resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized,
"Inactive user should not be able to login",
)
}
}
func (s *AuthE2ETestSuite) TestAuthTokensContainCorrectClaims() {
cfg := crypto.JWTConfig{
Secret: []byte("e2e-test-secret-key"),
ExpiryHours: 1,
RefreshExpiryHours: 24,
}
token, err := crypto.GenerateToken(cfg, 42, "claims@test.com", "administrator")
assert.NoError(s.T(), err)
claims, err := crypto.ValidateToken(cfg, token)
assert.NoError(s.T(), err)
assert.Equal(s.T(), uint(42), uint(claims["user_id"].(float64)))
assert.Equal(s.T(), "claims@test.com", claims["email"])
assert.Equal(s.T(), "administrator", claims["type"])
}
// TestAuthE2ESuite runs the Auth E2E test suite.
func TestAuthE2ESuite(t *testing.T) {
suite.Run(t, new(AuthE2ETestSuite))
}