(Feat): Initial Commit
This commit is contained in:
41
internal/api/handlers/auth/delete_user_account.go
Normal file
41
internal/api/handlers/auth/delete_user_account.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func DeleteUserAccountRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.DELETE("/account", deleteUserAccountHandler(s))
|
||||
}
|
||||
|
||||
func deleteUserAccountHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
user := auth.UserFromContext(ctx)
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.DeleteUserAccountPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := s.Auth.DeleteUserAccount(ctx, dto.DeleteUserAccountRequest{
|
||||
User: *user,
|
||||
CurrentPassword: swag.StringValue(body.CurrentPassword),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to delete user")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
136
internal/api/handlers/auth/delete_user_account_test.go
Normal file
136
internal/api/handlers/auth/delete_user_account_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func assertUserAndRelatedData(ctx context.Context, t *testing.T, s *api.Server, userID string, expectExists bool) {
|
||||
t.Helper()
|
||||
|
||||
userExists, err := models.Users(
|
||||
models.UserWhere.ID.EQ(userID),
|
||||
).Exists(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectExists, userExists)
|
||||
|
||||
appUserProfileExists, err := models.AppUserProfiles(
|
||||
models.AppUserProfileWhere.UserID.EQ(userID),
|
||||
).Exists(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectExists, appUserProfileExists)
|
||||
|
||||
accessTokenExists, err := models.AccessTokens(
|
||||
models.AccessTokenWhere.UserID.EQ(userID),
|
||||
).Exists(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectExists, accessTokenExists)
|
||||
|
||||
refreshTokenExists, err := models.RefreshTokens(
|
||||
models.RefreshTokenWhere.UserID.EQ(userID),
|
||||
).Exists(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectExists, refreshTokenExists)
|
||||
|
||||
pushTokenExists, err := models.PushTokens(
|
||||
models.PushTokenWhere.UserID.EQ(userID),
|
||||
).Exists(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectExists, pushTokenExists)
|
||||
}
|
||||
|
||||
func TestDeleteUserAccount(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
// expect the user to have a app user profile and different kinds of tokens (access, refresh, push, password reset)
|
||||
assertUserAndRelatedData(ctx, t, s, fix.User1.ID, true)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
// expect the user and all related data to be deleted
|
||||
assertUserAndRelatedData(ctx, t, s, fix.User1.ID, false)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserAccountCurrentPasswordWrong(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": "wrongpassword",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserAccountMissingCurrentPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserAccountNoAuth(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserAccountUserNotActive(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
ctx := t.Context()
|
||||
|
||||
fix.User1.IsActive = false
|
||||
_, err := fix.User1.Update(ctx, s.DB, boil.Whitelist(models.UserColumns.IsActive))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": "somepassword",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserAccountUserNotLocal(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
ctx := t.Context()
|
||||
|
||||
fix.User1.Password = null.String{}
|
||||
_, err := fix.User1.Update(ctx, s.DB, boil.Whitelist(models.UserColumns.Password))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": "wrongpassword",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser)
|
||||
})
|
||||
}
|
||||
39
internal/api/handlers/auth/get_complete_register.go
Normal file
39
internal/api/handlers/auth/get_complete_register.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/router/templates"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/url"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetCompleteRegisterRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.GET("/register", getCompleteRegisterHandler(s))
|
||||
}
|
||||
|
||||
func getCompleteRegisterHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
params := auth.NewGetCompleteRegisterRouteParams()
|
||||
if err := util.BindAndValidatePathAndQueryParams(c, ¶ms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
confirmationRequestURL, err := url.ConfirmationRequestURL(s.Config, params.Token.String())
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to generate confirmation link")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, templates.ViewTemplateAccountConfirmation.String(), map[string]interface{}{
|
||||
"confirmationRequestURL": confirmationRequestURL.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
27
internal/api/handlers/auth/get_complete_register_test.go
Normal file
27
internal/api/handlers/auth/get_complete_register_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetCompleteRegister(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", fmt.Sprintf("/api/v1/auth/register?token=%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
response, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.Snapshoter.SaveString(t, string(response))
|
||||
})
|
||||
}
|
||||
33
internal/api/handlers/auth/get_userinfo.go
Normal file
33
internal/api/handlers/auth/get_userinfo.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetUserInfoRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.GET("/userinfo", getUserInfoHandler(s))
|
||||
}
|
||||
|
||||
func getUserInfoHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
user := auth.UserFromContext(ctx)
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var err error
|
||||
|
||||
user.Profile, err = s.Auth.GetAppUserProfile(ctx, user.ID)
|
||||
if err != nil && !errors.Is(err, auth.ErrNotFound) {
|
||||
log.Debug().Err(err).Msg("Failed to get user profile")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, user.ToTypes())
|
||||
}
|
||||
}
|
||||
69
internal/api/handlers/auth/get_userinfo_test.go
Normal file
69
internal/api/handlers/auth/get_userinfo_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetUserInfo(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.GetUserInfoResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.Equal(t, fix.User1.ID, *response.Sub)
|
||||
assert.Equal(t, strfmt.Email(fix.User1.Username.String), response.Email)
|
||||
test.Snapshoter.Skip([]string{"UpdatedAt"}).Save(t, response)
|
||||
|
||||
for _, scope := range fix.User1.Scopes {
|
||||
assert.Contains(t, response.Scopes, scope)
|
||||
}
|
||||
|
||||
appUserProfile, err := models.FindAppUserProfile(ctx, s.DB, fix.User1.ID, models.AccessTokenColumns.UpdatedAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, appUserProfile.UpdatedAt.Unix(), *response.UpdatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUserInfoMinimal(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
_, err := models.AppUserProfiles(models.AppUserProfileWhere.UserID.EQ(fix.User1.ID)).DeleteAll(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.GetUserInfoResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.Equal(t, fix.User1.ID, *response.Sub)
|
||||
assert.Equal(t, strfmt.Email(fix.User1.Username.String), response.Email)
|
||||
|
||||
for _, scope := range fix.User1.Scopes {
|
||||
assert.Contains(t, response.Scopes, scope)
|
||||
}
|
||||
|
||||
user, err := models.FindUser(ctx, s.DB, fix.User1.ID, models.UserColumns.UpdatedAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, user.UpdatedAt.Unix(), *response.UpdatedAt)
|
||||
})
|
||||
}
|
||||
42
internal/api/handlers/auth/post_change_password.go
Normal file
42
internal/api/handlers/auth/post_change_password.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostChangePasswordRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/change-password", postChangePasswordHandler(s))
|
||||
}
|
||||
|
||||
func postChangePasswordHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
user := auth.UserFromEchoContext(c)
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostChangePasswordPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.Auth.UpdatePassword(ctx, dto.UpdatePasswordRequest{
|
||||
User: *user,
|
||||
CurrentPassword: swag.StringValue(body.CurrentPassword),
|
||||
NewPassword: swag.StringValue(body.NewPassword),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to update password")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
|
||||
}
|
||||
}
|
||||
187
internal/api/handlers/auth/post_change_password_test.go
Normal file
187
internal/api/handlers/auth/post_change_password_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
newPassword = "correct horse battery staple"
|
||||
)
|
||||
|
||||
func TestPostChangePasswordSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
"newPassword": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEqual(t, fix.User1AccessToken1.Token, *response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.NotEqual(t, fix.User1RefreshToken1.Token, *response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.User1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostChangePasswordInvalidPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": "not my password",
|
||||
"newPassword": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostChangePasswordDeactivatedUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
"newPassword": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.UserDeactivatedAccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostChangePasswordUserWithoutPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
"newPassword": newPassword,
|
||||
}
|
||||
|
||||
fix.User2.Password = null.String{}
|
||||
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAff)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User2AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser)
|
||||
|
||||
err = fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostChangePasswordBadRequest(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "MissingCurrentPassword",
|
||||
payload: test.GenericPayload{
|
||||
"newPassword": newPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MissingNewPassword",
|
||||
payload: test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyCurrentPassword",
|
||||
payload: test.GenericPayload{
|
||||
"currentPassword": "",
|
||||
"newPassword": newPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyNewPassword",
|
||||
payload: test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
"newPassword": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", tt.payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
40
internal/api/handlers/auth/post_complete_register.go
Normal file
40
internal/api/handlers/auth/post_complete_register.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/constants"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types/auth"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostCompleteRegisterRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST(fmt.Sprintf("/register/:%s", constants.RegistrationTokenParam), postCompleteRegisterHandler(s))
|
||||
}
|
||||
|
||||
func postCompleteRegisterHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
params := auth.NewPostCompleteRegisterRouteParams()
|
||||
if err := util.BindAndValidatePathAndQueryParams(c, ¶ms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.Auth.CompleteRegister(ctx, dto.CompleteRegisterRequest{
|
||||
ConfirmationToken: params.RegistrationToken.String(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to complete registration")
|
||||
return echo.ErrUnauthorized
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
|
||||
}
|
||||
}
|
||||
70
internal/api/handlers/auth/post_complete_register_test.go
Normal file
70
internal/api/handlers/auth/post_complete_register_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostCompleteRegister(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
ctx := t.Context()
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.ID.EQ(fix.UserRequiresConfirmationConfirmationToken.UserID),
|
||||
).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, user.IsActive)
|
||||
assert.False(t, user.RequiresConfirmation)
|
||||
|
||||
// trying again with the same token should fail
|
||||
{
|
||||
res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostCompleteRegisterTokenNotFound(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register/e45071b7-b9a0-4ed7-a5a0-16a16413d275", nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostCompleteRegisterTokenExpired(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
ctx := t.Context()
|
||||
|
||||
fix.UserRequiresConfirmationConfirmationToken.ValidUntil = s.Clock.Now().Add(-1 * time.Second)
|
||||
_, err := fix.UserRequiresConfirmationConfirmationToken.Update(ctx, s.DB, boil.Whitelist(models.ConfirmationTokenColumns.ValidUntil))
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
57
internal/api/handlers/auth/post_forgot_password.go
Normal file
57
internal/api/handlers/auth/post_forgot_password.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/url"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func PostForgotPasswordRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/forgot-password", postForgotPasswordHandler(s))
|
||||
}
|
||||
|
||||
func postForgotPasswordHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
var body types.PostForgotPasswordPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := dto.NewUsername(body.Username.String())
|
||||
|
||||
result, err := s.Auth.InitPasswordReset(ctx, dto.InitPasswordResetRequest{
|
||||
Username: username,
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to initiate password reset")
|
||||
return err
|
||||
}
|
||||
|
||||
if result.ResetToken.IsZero() {
|
||||
log.Debug().Msg("Failed to initiate password reset, no token returned")
|
||||
// Return success status to prevent user enumeration
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
resetLink, err := url.PasswordResetDeeplinkURL(s.Config, result.ResetToken.String)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to generate password reset link")
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.Mailer.SendPasswordReset(ctx, username.String(), resetLink.String()); err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to send password reset email")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
39
internal/api/handlers/auth/post_forgot_password_complete.go
Normal file
39
internal/api/handlers/auth/post_forgot_password_complete.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostForgotPasswordCompleteRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/forgot-password/complete", postForgotPasswordCompleteHandler(s))
|
||||
}
|
||||
|
||||
func postForgotPasswordCompleteHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostForgotPasswordCompletePayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.Auth.ResetPassword(ctx, dto.ResetPasswordRequest{
|
||||
ResetToken: body.Token.String(),
|
||||
NewPassword: swag.StringValue(body.Password),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to reset password")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
|
||||
}
|
||||
}
|
||||
280
internal/api/handlers/auth/post_forgot_password_complete_test.go
Normal file
280
internal/api/handlers/auth/post_forgot_password_complete_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostForgotPasswordCompleteSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
passwordResetToken := models.PasswordResetToken{
|
||||
UserID: fix.User1.ID,
|
||||
ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity),
|
||||
}
|
||||
|
||||
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"token": passwordResetToken.Token,
|
||||
"password": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.NotEqual(t, fix.User1RefreshToken1.Token, *response.RefreshToken)
|
||||
test.Snapshoter.Skip([]string{"AccessToken", "RefreshToken"}).Save(t, response)
|
||||
|
||||
err = fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.User1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordCompleteUnknownToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"token": "fd5c04ea-f39c-49e9-bb40-7f570ed1f66f",
|
||||
"password": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrNotFoundTokenNotFound)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.User1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordCompleteExpiredToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
passwordResetToken := models.PasswordResetToken{
|
||||
UserID: fix.User1.ID,
|
||||
ValidUntil: s.Clock.Now().Add(time.Minute * -10),
|
||||
}
|
||||
|
||||
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"token": passwordResetToken.Token,
|
||||
"password": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrConflictTokenExpired)
|
||||
|
||||
err = fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.User1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordCompleteDeactivatedUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
passwordResetToken := models.PasswordResetToken{
|
||||
UserID: fix.UserDeactivated.ID,
|
||||
ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity),
|
||||
}
|
||||
|
||||
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"token": passwordResetToken.Token,
|
||||
"password": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
|
||||
|
||||
err = fix.UserDeactivatedAccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.UserDeactivatedRefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.UserDeactivated.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.UserDeactivated.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.UserDeactivated.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fixtures.HashedTestUserPassword, fix.UserDeactivated.Password.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordCompleteUserWithoutPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
passwordResetToken := models.PasswordResetToken{
|
||||
UserID: fix.User2.ID,
|
||||
ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity),
|
||||
}
|
||||
|
||||
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"token": passwordResetToken.Token,
|
||||
"password": newPassword,
|
||||
}
|
||||
|
||||
fix.User2.Password = null.String{}
|
||||
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAff)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser)
|
||||
|
||||
err = fix.User2AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User2RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.User2.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.User2.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.User2.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, fix.User2.Password.Valid)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordCompleteBadRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "MissingToken",
|
||||
payload: test.GenericPayload{
|
||||
"password": "correct horse battery stable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MissingPassword",
|
||||
payload: test.GenericPayload{
|
||||
"token": "7b6e2366-7806-421f-bd56-ffcb39d7b1ee",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidToken",
|
||||
payload: test.GenericPayload{
|
||||
"token": "definitelydoesnotexist",
|
||||
"password": "correct horse battery stable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyToken",
|
||||
payload: test.GenericPayload{
|
||||
"password": "correct horse battery stable",
|
||||
"token": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyPassword",
|
||||
payload: test.GenericPayload{
|
||||
"token": "42deb737-fa9c-4e9e-bdce-e33b829c72f7",
|
||||
"password": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", tt.payload, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
256
internal/api/handlers/auth/post_forgot_password_test.go
Normal file
256
internal/api/handlers/auth/post_forgot_password_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostForgotPasswordSuccess(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Auth.PasswordResetTokenReuseDuration = 120 * time.Second
|
||||
config.Auth.PasswordResetTokenDebounceDuration = 60 * time.Second
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
passwordResetToken, err := fix.User1.PasswordResetTokens().One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
require.NotNil(t, mail)
|
||||
assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetToken.Token))
|
||||
|
||||
// retrying should not send a new mail because of the debounce time
|
||||
{
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
sentMails := test.GetSentMails(t, s.Mailer)
|
||||
assert.Len(t, sentMails, 1)
|
||||
}
|
||||
|
||||
// CreatedAt of token exceeds debounce time, retrying should send a new mail
|
||||
// but with the same token as the reuse duration has not passed yet
|
||||
{
|
||||
passwordResetToken.CreatedAt = s.Clock.Now().Add(-s.Config.Auth.PasswordResetTokenDebounceDuration)
|
||||
_, err = passwordResetToken.Update(ctx, s.DB, boil.Whitelist(models.PasswordResetTokenColumns.CreatedAt))
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
sentMails := test.GetSentMails(t, s.Mailer)
|
||||
require.Len(t, sentMails, 2)
|
||||
|
||||
passwordResetTokens, err := fix.User1.PasswordResetTokens().All(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, passwordResetTokens, 1)
|
||||
for _, mail := range sentMails {
|
||||
assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token))
|
||||
}
|
||||
}
|
||||
|
||||
// CreatedAt of token exceeds reuse time, retrying should send a new mail with a new token
|
||||
{
|
||||
passwordResetToken.CreatedAt = s.Clock.Now().Add(-s.Config.Auth.PasswordResetTokenReuseDuration)
|
||||
_, err = passwordResetToken.Update(ctx, s.DB, boil.Whitelist(models.PasswordResetTokenColumns.CreatedAt))
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
sentMails := test.GetSentMails(t, s.Mailer)
|
||||
require.Len(t, sentMails, 3)
|
||||
|
||||
passwordResetTokens, err := fix.User1.PasswordResetTokens(
|
||||
db.OrderBy(types.OrderDirDesc, models.PasswordResetTokenColumns.CreatedAt),
|
||||
).All(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, passwordResetTokens, 2)
|
||||
|
||||
assert.Contains(t, string(sentMails[2].HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token))
|
||||
}
|
||||
|
||||
// Token validity is expired, retrying should send a new mail with a new token
|
||||
{
|
||||
_, err = models.PasswordResetTokens().UpdateAll(ctx, s.DB, models.M{
|
||||
models.PasswordResetTokenColumns.ValidUntil: s.Clock.Now().Add(-1 * time.Second),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
sentMails := test.GetSentMails(t, s.Mailer)
|
||||
require.Len(t, sentMails, 4)
|
||||
|
||||
passwordResetTokens, err := fix.User1.PasswordResetTokens(
|
||||
db.OrderBy(types.OrderDirDesc, models.PasswordResetTokenColumns.CreatedAt),
|
||||
).All(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, passwordResetTokens, 3)
|
||||
|
||||
assert.Contains(t, string(sentMails[3].HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordSuccessLowercaseTrimWhitespaces(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fmt.Sprintf(" %s ", strings.ToUpper(fix.User1.Username.String)),
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
passwordResetToken, err := fix.User1.PasswordResetTokens().One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
require.NotNil(t, mail)
|
||||
assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetToken.Token))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordUnknownUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"username": "definitelydoesnotexist@example.com",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), cnt)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
assert.Nil(t, mail)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordDeactivatedUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.UserDeactivated.Username,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), cnt)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
assert.Nil(t, mail)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordUserWithoutPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User2.Username,
|
||||
}
|
||||
|
||||
fix.User2.Password = null.String{}
|
||||
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAff)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), cnt)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
assert.Nil(t, mail)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordBadRequest(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "MissingUsername",
|
||||
payload: test.GenericPayload{},
|
||||
},
|
||||
{
|
||||
name: "EmptyUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "definitely not an email",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", tt.payload, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
|
||||
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), cnt)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
assert.Nil(t, mail)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
39
internal/api/handlers/auth/post_login.go
Normal file
39
internal/api/handlers/auth/post_login.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostLoginRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/login", postLoginHandler(s))
|
||||
}
|
||||
|
||||
func postLoginHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostLoginPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.Auth.Login(ctx, dto.LoginRequest{
|
||||
Username: dto.NewUsername(body.Username.String()),
|
||||
Password: swag.StringValue(body.Password),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to authenticate user")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
|
||||
}
|
||||
}
|
||||
179
internal/api/handlers/auth/post_login_test.go
Normal file
179
internal/api/handlers/auth/post_login_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostLoginSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginSuccessLowercaseTrimWhitespaces(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fmt.Sprintf(" %s ", strings.ToUpper(fix.User1.Username.String)),
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginInvalidCredentials(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
"password": "not my password",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginUnknownUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
payload := test.GenericPayload{
|
||||
"username": "definitelydoesnotexist@example.com",
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginDeactivatedUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.UserDeactivated.Username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginUserWithoutPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User2.Username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
fix.User2.Password = null.String{}
|
||||
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAff)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginBadRequest(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "InvalidUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "definitely not an email",
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MissingUsername",
|
||||
payload: test.GenericPayload{
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MissingPassword",
|
||||
payload: test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "",
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyPassword",
|
||||
payload: test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
"password": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", tt.payload, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
45
internal/api/handlers/auth/post_logout.go
Normal file
45
internal/api/handlers/auth/post_logout.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostLogoutRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/logout", postLogoutHandler(s))
|
||||
}
|
||||
|
||||
func postLogoutHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostLogoutPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request := dto.LogoutRequest{
|
||||
AccessToken: *auth.AccessTokenFromEchoContext(c),
|
||||
}
|
||||
|
||||
if len(body.RefreshToken.String()) > 0 {
|
||||
request.RefreshToken = null.StringFrom(body.RefreshToken.String())
|
||||
}
|
||||
|
||||
err := s.Auth.Logout(ctx, request)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to logout user")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
129
internal/api/handlers/auth/post_logout_test.go
Normal file
129
internal/api/handlers/auth/post_logout_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostLogoutSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogoutSuccessWithRefreshToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": fix.User1RefreshToken1.Token,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogoutSuccessWithUnknownRefreshToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": "93d8ccd0-be30-4661-a428-cbe74e1a3ffe",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogoutInvalidRefreshToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": "not my refresh token",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogoutError(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expectedError *httperrors.HTTPError
|
||||
headers http.Header
|
||||
}{
|
||||
{
|
||||
name: "InvalidAuthToken",
|
||||
expectedError: middleware.ErrBadRequestMalformedToken,
|
||||
headers: test.HeadersWithAuth(t, "not my auth token"),
|
||||
},
|
||||
{
|
||||
name: "UnknownAuthToken",
|
||||
expectedError: httperrors.NewFromEcho(echo.ErrUnauthorized),
|
||||
headers: test.HeadersWithAuth(t, "25e8630e-9a41-4f38-8339-373f0c203cef"),
|
||||
},
|
||||
{
|
||||
name: "MissingAuthToken",
|
||||
expectedError: httperrors.NewFromEcho(echo.ErrUnauthorized),
|
||||
headers: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", nil, tt.headers)
|
||||
test.RequireHTTPError(t, res, tt.expectedError)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
37
internal/api/handlers/auth/post_refresh.go
Normal file
37
internal/api/handlers/auth/post_refresh.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostRefreshRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/refresh", postRefreshHandler(s))
|
||||
}
|
||||
|
||||
func postRefreshHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostRefreshPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.Auth.Refresh(ctx, dto.RefreshRequest{
|
||||
RefreshToken: body.RefreshToken.String(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to refresh tokens")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
|
||||
}
|
||||
}
|
||||
108
internal/api/handlers/auth/post_refresh_test.go
Normal file
108
internal/api/handlers/auth/post_refresh_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostRefreshSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": fix.User1RefreshToken1.Token,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
|
||||
err := fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRefreshUnknownToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": "c094e933-e5f0-4ece-9c10-914f3122cdb6",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRefreshDeactivatedUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": fix.UserDeactivatedRefreshToken1.Token,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
|
||||
|
||||
err := fix.UserDeactivatedRefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRefreshBadRequest(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "MissingRefreshToken",
|
||||
payload: test.GenericPayload{},
|
||||
},
|
||||
{
|
||||
name: "EmptyRefreshToken",
|
||||
payload: test.GenericPayload{
|
||||
"refresh_token": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidToken",
|
||||
payload: test.GenericPayload{
|
||||
"refresh_token": "not a valid token",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", tt.payload, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
72
internal/api/handlers/auth/post_register.go
Normal file
72
internal/api/handlers/auth/post_register.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/url"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostRegisterRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/register", postRegisterHandler(s))
|
||||
}
|
||||
|
||||
func postRegisterHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostRegisterPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := dto.NewUsername(body.Username.String())
|
||||
|
||||
result, err := s.Auth.Register(ctx, dto.RegisterRequest{
|
||||
Username: username,
|
||||
Password: swag.StringValue(body.Password),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to register user")
|
||||
return err
|
||||
}
|
||||
|
||||
if !result.RequiresConfirmation {
|
||||
loginResult, err := s.Auth.Login(ctx, dto.LoginRequest{
|
||||
Username: username,
|
||||
Password: swag.StringValue(body.Password),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to authenticate user after registration")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, loginResult.ToTypes())
|
||||
}
|
||||
|
||||
if result.ConfirmationToken.Valid {
|
||||
confirmationLink, err := url.ConfirmationDeeplinkURL(s.Config, result.ConfirmationToken.String)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to generate confirmation link")
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.Mailer.SendAccountConfirmation(ctx, username.String(), dto.ConfirmatioNotificationPayload{
|
||||
ConfirmationLink: confirmationLink.String(),
|
||||
}); err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to send confirmation email")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusAccepted, &types.RegisterResponse{
|
||||
RequiresConfirmation: swag.Bool(result.RequiresConfirmation),
|
||||
})
|
||||
}
|
||||
}
|
||||
334
internal/api/handlers/auth/post_register_test.go
Normal file
334
internal/api/handlers/auth/post_register_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/url"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostRegisterSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
now := time.Date(2025, 2, 5, 11, 42, 30, 0, time.UTC)
|
||||
test.SetMockClock(t, s, now)
|
||||
|
||||
username := "usernew@example.com"
|
||||
payload := test.GenericPayload{
|
||||
"username": username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(null.StringFrom(username)),
|
||||
qm.Load(models.UserRels.AppUserProfile),
|
||||
qm.Load(models.UserRels.AccessTokens),
|
||||
qm.Load(models.UserRels.RefreshTokens),
|
||||
).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, null.StringFrom(username), user.Username)
|
||||
assert.True(t, user.LastAuthenticatedAt.Valid)
|
||||
assert.Equal(t, now, user.LastAuthenticatedAt.Time)
|
||||
assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes)
|
||||
|
||||
assert.NotNil(t, user.R.AppUserProfile)
|
||||
assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid)
|
||||
|
||||
assert.Len(t, user.R.AccessTokens, 1)
|
||||
assert.Equal(t, strfmt.UUID4(user.R.AccessTokens[0].Token), *response.AccessToken)
|
||||
assert.Len(t, user.R.RefreshTokens, 1)
|
||||
assert.Equal(t, strfmt.UUID4(user.R.RefreshTokens[0].Token), *response.RefreshToken)
|
||||
|
||||
res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res2.Result().StatusCode)
|
||||
|
||||
var response2 types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res2, &response2)
|
||||
|
||||
assert.NotEmpty(t, response2.AccessToken)
|
||||
assert.NotEqual(t, response.AccessToken, *response2.AccessToken)
|
||||
assert.NotEmpty(t, response2.RefreshToken)
|
||||
assert.NotEqual(t, response.RefreshToken, *response2.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response2.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response2.TokenType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRegisterWithConfirmationSuccess(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Auth.RegistrationRequiresConfirmation = true
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
username := "usernew-with-confirmation@example.com"
|
||||
payload := test.GenericPayload{
|
||||
"username": username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, res.Result().StatusCode)
|
||||
|
||||
var response types.RegisterResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.True(t, swag.BoolValue(response.RequiresConfirmation))
|
||||
|
||||
// expect the user to be normally created
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(null.StringFrom(username)),
|
||||
qm.Load(models.UserRels.AppUserProfile),
|
||||
qm.Load(models.UserRels.AccessTokens),
|
||||
qm.Load(models.UserRels.RefreshTokens),
|
||||
qm.Load(models.UserRels.ConfirmationTokens),
|
||||
).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, null.StringFrom(username), user.Username)
|
||||
assert.True(t, user.LastAuthenticatedAt.Valid)
|
||||
assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes)
|
||||
assert.False(t, user.IsActive)
|
||||
assert.True(t, user.RequiresConfirmation)
|
||||
|
||||
assert.NotNil(t, user.R.AppUserProfile)
|
||||
assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid)
|
||||
|
||||
// expect the user to have no access or refresh tokens
|
||||
assert.Empty(t, user.R.AccessTokens)
|
||||
assert.Empty(t, user.R.RefreshTokens)
|
||||
require.Len(t, user.R.ConfirmationTokens, 1)
|
||||
confirmationToken := user.R.ConfirmationTokens[0]
|
||||
|
||||
// expect the login to fail
|
||||
res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
test.RequireHTTPError(t, res2, httperrors.ErrForbiddenUserDeactivated)
|
||||
|
||||
// expect the confirmation email to be sent
|
||||
mails := test.GetSentMails(t, s.Mailer)
|
||||
require.Len(t, mails, 1)
|
||||
|
||||
mail := mails[0]
|
||||
expectedConfirmationLink, err := url.ConfirmationDeeplinkURL(s.Config, confirmationToken.Token)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, username, mail.To[0])
|
||||
assert.Contains(t, string(mail.HTML), expectedConfirmationLink.String())
|
||||
|
||||
// directly register again should trigger the debounce
|
||||
// and not create a new confirmation token
|
||||
registerAgainRes := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
require.Equal(t, http.StatusAccepted, registerAgainRes.Result().StatusCode)
|
||||
|
||||
var registerAgainResponse types.RegisterResponse
|
||||
test.ParseResponseAndValidate(t, registerAgainRes, ®isterAgainResponse)
|
||||
|
||||
// expect the confirmation to be required
|
||||
assert.True(t, swag.BoolValue(registerAgainResponse.RequiresConfirmation))
|
||||
|
||||
confirmationTokenCount, err := user.ConfirmationTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, 1, confirmationTokenCount)
|
||||
|
||||
// register later again
|
||||
test.SetMockClock(t, s, s.Clock.Now().Add(config.Auth.ConfirmationTokenDebounceDuration+time.Second))
|
||||
|
||||
registerLaterAgainRes := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
require.Equal(t, http.StatusAccepted, registerLaterAgainRes.Result().StatusCode)
|
||||
|
||||
var registerLaterAgainResponse types.RegisterResponse
|
||||
test.ParseResponseAndValidate(t, registerLaterAgainRes, ®isterLaterAgainResponse)
|
||||
assert.True(t, swag.BoolValue(registerLaterAgainResponse.RequiresConfirmation))
|
||||
|
||||
confirmationTokens, err := models.ConfirmationTokens(
|
||||
models.ConfirmationTokenWhere.UserID.EQ(user.ID),
|
||||
db.OrderBy(types.OrderDirDesc, models.ConfirmationTokenColumns.CreatedAt),
|
||||
).All(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, confirmationTokens, 2)
|
||||
|
||||
lastSentMail := test.GetLastSentMail(t, s.Mailer)
|
||||
require.NotNil(t, lastSentMail)
|
||||
|
||||
expectedConfirmationLink, err = url.ConfirmationDeeplinkURL(s.Config, confirmationTokens[0].Token)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, username, lastSentMail.To[0])
|
||||
assert.Contains(t, string(lastSentMail.HTML), expectedConfirmationLink.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRegisterSuccessLowercaseTrimWhitespaces(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
username := " USERNEW@example.com "
|
||||
usernameLowerTrimmed := "usernew@example.com"
|
||||
payload := test.GenericPayload{
|
||||
"username": username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
"name": "Trim Whitespaces",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(null.StringFrom(usernameLowerTrimmed)),
|
||||
qm.Load(models.UserRels.AppUserProfile),
|
||||
qm.Load(models.UserRels.AccessTokens),
|
||||
qm.Load(models.UserRels.RefreshTokens),
|
||||
).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, null.StringFrom(usernameLowerTrimmed), user.Username)
|
||||
assert.True(t, user.LastAuthenticatedAt.Valid)
|
||||
assert.WithinDuration(t, s.Clock.Now(), user.LastAuthenticatedAt.Time, time.Second*10)
|
||||
assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes)
|
||||
|
||||
assert.NotNil(t, user.R.AppUserProfile)
|
||||
assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid)
|
||||
|
||||
assert.Len(t, user.R.AccessTokens, 1)
|
||||
assert.Equal(t, strfmt.UUID4(user.R.AccessTokens[0].Token), *response.AccessToken)
|
||||
assert.Len(t, user.R.RefreshTokens, 1)
|
||||
assert.Equal(t, strfmt.UUID4(user.R.RefreshTokens[0].Token), *response.RefreshToken)
|
||||
|
||||
res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res2.Result().StatusCode)
|
||||
|
||||
var response2 types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res2, &response2)
|
||||
|
||||
assert.NotEmpty(t, response2.AccessToken)
|
||||
assert.NotEqual(t, response.AccessToken, *response2.AccessToken)
|
||||
assert.NotEmpty(t, response2.RefreshToken)
|
||||
assert.NotEqual(t, response.RefreshToken, *response2.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response2.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response2.TokenType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRegisterAlreadyExists(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrConflictUserAlreadyExists)
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(fix.User1.Username),
|
||||
qm.Load(models.UserRels.AppUserProfile),
|
||||
qm.Load(models.UserRels.AccessTokens),
|
||||
qm.Load(models.UserRels.RefreshTokens),
|
||||
).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, fix.User1.ID)
|
||||
|
||||
assert.NotNil(t, user.R.AppUserProfile)
|
||||
assert.Len(t, user.R.AccessTokens, 1)
|
||||
assert.Len(t, user.R.RefreshTokens, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRegisterBadRequest(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "MissingUsername",
|
||||
payload: test.GenericPayload{
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MissingPassword",
|
||||
payload: test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "definitely not an email",
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "",
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyPassword",
|
||||
payload: test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
"password": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", tt.payload, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user