526 lines
15 KiB
Go
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))
|
|
}
|