(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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
55
internal/api/handlers/common/get_healthy.go
Normal file
55
internal/api/handlers/common/get_healthy.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
// We use 521 to indicate an error state
|
||||
// same as Cloudflare: https://support.cloudflare.com/hc/en-us/articles/115003011431#521error
|
||||
httpStatusDown = 521
|
||||
)
|
||||
|
||||
func GetHealthyRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.Management.GET("/healthy", getHealthyHandler(s))
|
||||
}
|
||||
|
||||
// Heathly check (= liveness)
|
||||
// Returns an human readable string about the current service status.
|
||||
// In addition to readiness probes, it performs actual write probes.
|
||||
// Note that /-/healthy is private (shielded by the mgmt-secret) as it may expose sensitive information about your service.
|
||||
// Structured upon https://prometheus.io/docs/prometheus/latest/management_api/
|
||||
func getHealthyHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if !s.Ready() {
|
||||
return c.String(httpStatusDown, "Not ready.")
|
||||
}
|
||||
|
||||
var str strings.Builder
|
||||
fmt.Fprintln(&str, "Ready.")
|
||||
|
||||
// General Timeout and associated context.
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), s.Config.Management.LivenessTimeout)
|
||||
defer cancel()
|
||||
|
||||
healthyStr, errs := ProbeLiveness(ctx, s.DB, s.Config.Management.ProbeWriteablePathsAbs, s.Config.Management.ProbeWriteableTouchfile)
|
||||
str.WriteString(healthyStr)
|
||||
|
||||
// Finally return the health status according to the seen states
|
||||
if ctx.Err() != nil || len(errs) != 0 {
|
||||
fmt.Fprintln(&str, "Probes failed.")
|
||||
return c.String(httpStatusDown, str.String())
|
||||
}
|
||||
|
||||
fmt.Fprintln(&str, "Probes succeeded.")
|
||||
|
||||
return c.String(http.StatusOK, str.String())
|
||||
}
|
||||
}
|
||||
110
internal/api/handlers/common/get_healthy_test.go
Normal file
110
internal/api/handlers/common/get_healthy_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetHealthySuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// explicitly set touchfile that no other test has (so we can explicitly remove it beforehand.)
|
||||
s.Config.Management.ProbeWriteableTouchfile = ".healthy-test"
|
||||
|
||||
for _, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs {
|
||||
os.Remove(path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile))
|
||||
|
||||
// also remove after test completion.
|
||||
defer os.Remove(path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile))
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
require.Contains(t, res.Body.String(), "seq_health=1")
|
||||
|
||||
firstTouchTime := make([]time.Time, len(s.Config.Management.ProbeWriteablePathsAbs))
|
||||
|
||||
// expect a new touchfiles were written
|
||||
for i, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs {
|
||||
filePath := path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile)
|
||||
stat, err := os.Stat(filePath)
|
||||
require.NoErrorf(t, err, "Expected to have %v", filePath)
|
||||
firstTouchTime[i] = stat.ModTime()
|
||||
}
|
||||
|
||||
res = test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
require.Contains(t, res.Body.String(), "seq_health=2")
|
||||
|
||||
// expect touchfiles modTime was updated
|
||||
for i, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs {
|
||||
filePath := path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile)
|
||||
stat, err := os.Stat(filePath)
|
||||
require.NoErrorf(t, err, "Expected to have %v", filePath)
|
||||
|
||||
assert.NotEqual(t, firstTouchTime[i], stat.ModTime())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyNoAuth(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy", nil, nil)
|
||||
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyWrongAuth(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret=i-have-no-idea-about-the-pass", nil, nil)
|
||||
require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyDBPingError(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// forcefully close the DB
|
||||
s.DB.Close()
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyDBSeqError(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// forcefully remove the sequence
|
||||
if _, err := s.DB.Exec("DROP SEQUENCE seq_health;"); err != nil {
|
||||
t.Fatal(err, "was unable to drop sequence seq_health")
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyMountError(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
s.Config.Management.ProbeWriteablePathsAbs = []string{"/this/path/does/not/exist"}
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyNotReady(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// forcefully remove an initialized component to check if ready state works
|
||||
s.Mailer = nil
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
40
internal/api/handlers/common/get_ready.go
Normal file
40
internal/api/handlers/common/get_ready.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetReadyRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.Management.GET("/ready", getReadyHandler(s))
|
||||
}
|
||||
|
||||
// Readiness check
|
||||
// This endpoint returns 200 when our Service is ready to serve traffic (i.e. respond to queries).
|
||||
// Does read-only probes apart from the general server ready state.
|
||||
// Note that /-/ready is typically public (and not shielded by a mgmt-secret), we thus prevent information leakage here and only return `"Ready."`.
|
||||
// Structured upon https://prometheus.io/docs/prometheus/latest/management_api/
|
||||
func getReadyHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if !s.Ready() {
|
||||
return c.String(httpStatusDown, "Not ready.")
|
||||
}
|
||||
|
||||
// General Timeout and associated context.
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), s.Config.Management.ReadinessTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, errs := ProbeReadiness(ctx, s.DB, s.Config.Management.ProbeWriteablePathsAbs)
|
||||
|
||||
// Finally return the health status according to the seen states
|
||||
if ctx.Err() != nil || len(errs) != 0 {
|
||||
return c.String(httpStatusDown, "Not ready.")
|
||||
}
|
||||
|
||||
return c.String(http.StatusOK, "Ready.")
|
||||
}
|
||||
}
|
||||
41
internal/api/handlers/common/get_ready_test.go
Normal file
41
internal/api/handlers/common/get_ready_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetReadyReadiness(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
require.Equal(t, "Ready.", res.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetReadyReadinessBroken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// forcefully remove an initialized component to check if ready state works
|
||||
s.Mailer = nil
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
require.Equal(t, "Not ready.", res.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetReadyDBBrokenNotReady(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// forcefully remove pg
|
||||
err := s.DB.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
require.Equal(t, "Not ready.", res.Body.String())
|
||||
})
|
||||
}
|
||||
20
internal/api/handlers/common/get_swagger.go
Normal file
20
internal/api/handlers/common/get_swagger.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetSwaggerRoute(s *api.Server) *echo.Route {
|
||||
// ---
|
||||
// Serve generated swagger.yml file statically at /swagger.yml
|
||||
// hack: not attached to group - can go away after echo/group.go .File and .Static actually return the *echo.Route
|
||||
// see https://github.com/labstack/echo/issues/1595
|
||||
// return s.Router.Root.File("swagger.yml", filepath.Join(s.Config.Echo.APIBaseDirAbs, "swagger.yml"))
|
||||
// we explicitly enforce a no-cache directive on any requests to it.
|
||||
return s.Echo.File("/swagger.yml", filepath.Join(s.Config.Paths.APIBaseDirAbs, "swagger.yml"), middleware.NoCache())
|
||||
}
|
||||
32
internal/api/handlers/common/get_swagger_test.go
Normal file
32
internal/api/handlers/common/get_swagger_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSwaggerYAMLRetrieval(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/swagger.yml", nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
// caching: ensure this call is always uncached for browsers
|
||||
assert.Equal(t, "no-cache, private, max-age=0", res.Header().Get("Cache-Control"))
|
||||
assert.Equal(t, "Thu, 01 Jan 1970 00:00:00 UTC", res.Header().Get("Expires"))
|
||||
assert.Equal(t, "0", res.Header().Get("X-Accel-Expires"))
|
||||
assert.Equal(t, "no-cache", res.Header().Get("Pragma"))
|
||||
|
||||
// caching: unset
|
||||
assert.Empty(t, res.Header().Get("ETag"))
|
||||
assert.Empty(t, res.Header().Get("If-Modified-Since"))
|
||||
assert.Empty(t, res.Header().Get("If-Match"))
|
||||
assert.Empty(t, res.Header().Get("If-None-Match"))
|
||||
assert.Empty(t, res.Header().Get("If-Range"))
|
||||
assert.Empty(t, res.Header().Get("If-Unmodified-Since"))
|
||||
})
|
||||
}
|
||||
21
internal/api/handlers/common/get_version.go
Normal file
21
internal/api/handlers/common/get_version.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetVersionRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.Management.GET("/version", getVersionHandler(s))
|
||||
}
|
||||
|
||||
// Returns the version and build date baked into the binary.
|
||||
func getVersionHandler(_ *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, config.GetFormattedBuildArgs())
|
||||
}
|
||||
}
|
||||
33
internal/api/handlers/common/get_version_test.go
Normal file
33
internal/api/handlers/common/get_version_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetVersion(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/-/version?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
require.Equal(t, "build.local/misses/ldflags @ < 40 chars git commit hash via ldflags > (1970-01-01T00:00:00+00:00)", res.Body.String()) // build args are not injected during test time.
|
||||
|
||||
// caching: ensure this call is always uncached for browsers
|
||||
assert.Equal(t, "no-cache, private, max-age=0", res.Header().Get("Cache-Control"))
|
||||
assert.Equal(t, "Thu, 01 Jan 1970 00:00:00 UTC", res.Header().Get("Expires"))
|
||||
assert.Equal(t, "0", res.Header().Get("X-Accel-Expires"))
|
||||
assert.Equal(t, "no-cache", res.Header().Get("Pragma"))
|
||||
|
||||
// caching: unset
|
||||
assert.Empty(t, res.Header().Get("ETag"))
|
||||
assert.Empty(t, res.Header().Get("If-Modified-Since"))
|
||||
assert.Empty(t, res.Header().Get("If-Match"))
|
||||
assert.Empty(t, res.Header().Get("If-None-Match"))
|
||||
assert.Empty(t, res.Header().Get("If-Range"))
|
||||
assert.Empty(t, res.Header().Get("If-Unmodified-Since"))
|
||||
})
|
||||
}
|
||||
226
internal/api/handlers/common/probes.go
Normal file
226
internal/api/handlers/common/probes.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func ProbeReadiness(ctx context.Context, database *sql.DB, writeablePaths []string) (string, []error) {
|
||||
var str strings.Builder
|
||||
|
||||
// slice collects all errors from probes
|
||||
errs := make([]error, 0, 1+len(writeablePaths))
|
||||
|
||||
// DB readable?
|
||||
dbPingStr, dbPingErr := probeDatabasePingable(ctx, database)
|
||||
str.WriteString(dbPingStr)
|
||||
|
||||
if dbPingErr != nil {
|
||||
errs = append(errs, dbPingErr)
|
||||
}
|
||||
|
||||
// FS (potentially) writeable?
|
||||
for _, writeablePath := range writeablePaths {
|
||||
fsPermStr, fsPermErr := probePathWriteablePermission(ctx, writeablePath)
|
||||
str.WriteString(fsPermStr)
|
||||
|
||||
if fsPermErr != nil {
|
||||
errs = append(errs, fsPermErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Feel free to add additional probes here...
|
||||
|
||||
return str.String(), errs
|
||||
}
|
||||
|
||||
func ProbeLiveness(ctx context.Context, database *sql.DB, writeablePaths []string, touch string) (string, []error) {
|
||||
// fail immediately if any readiness probes above have already failed.
|
||||
readinessProbeStr, readinessProbeErrs := ProbeReadiness(ctx, database, writeablePaths)
|
||||
|
||||
if len(readinessProbeErrs) != 0 {
|
||||
return readinessProbeStr, readinessProbeErrs
|
||||
}
|
||||
|
||||
var str strings.Builder
|
||||
|
||||
// include previous readiness probe results in final string
|
||||
str.WriteString(readinessProbeStr)
|
||||
|
||||
// slice collects all errors from probes
|
||||
errs := make([]error, 0, 1+len(writeablePaths))
|
||||
|
||||
// DB writeable?
|
||||
dbHealthStr, dbHealthErr := probeDatabaseNextHealthSequence(ctx, database)
|
||||
str.WriteString(dbHealthStr)
|
||||
|
||||
if dbHealthErr != nil {
|
||||
errs = append(errs, dbHealthErr)
|
||||
}
|
||||
|
||||
// FS writeable?
|
||||
for _, writeablePath := range writeablePaths {
|
||||
fsTouchStr, fsTouchErr := probePathWriteableTouch(ctx, writeablePath, touch)
|
||||
str.WriteString(fsTouchStr)
|
||||
if fsTouchErr != nil {
|
||||
errs = append(errs, fsTouchErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Feel free to add additional probes here...
|
||||
|
||||
return str.String(), errs
|
||||
}
|
||||
|
||||
// FS (especially hard mounted NFS paths) or PostgreSQL calls may be blocking or running for too long and thus need to run detached
|
||||
// We additionally want them to timeout (e.g. useful for hard mounted NFS paths)
|
||||
// Typically a any context used here will already have a deadline associated
|
||||
// If not we will explicitly return a short one here.
|
||||
func ensureProbeDeadlineFromContext(ctx context.Context) time.Time {
|
||||
ctxDeadline, hasDeadline := ctx.Deadline()
|
||||
if !hasDeadline {
|
||||
ctxDeadline = time.Now().Add(1 * time.Second)
|
||||
}
|
||||
|
||||
return ctxDeadline
|
||||
}
|
||||
|
||||
func probeDatabasePingable(ctx context.Context, database *sql.DB) (string, error) {
|
||||
var str strings.Builder
|
||||
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
|
||||
dbPingStart := time.Now()
|
||||
|
||||
var dbPingWg sync.WaitGroup
|
||||
var dbErr error
|
||||
|
||||
dbPingWg.Add(1)
|
||||
go func() {
|
||||
dbErr = database.PingContext(ctx)
|
||||
dbPingWg.Done()
|
||||
}()
|
||||
|
||||
if err := util.WaitTimeout(&dbPingWg, time.Until(ctxDeadline)); err != nil {
|
||||
fmt.Fprintf(&str, "Probe db: Ping deadline after %s, error=%v.\n", time.Since(dbPingStart), err.Error())
|
||||
return str.String(), err
|
||||
}
|
||||
|
||||
if dbErr != nil {
|
||||
fmt.Fprintf(&str, "Probe db: Ping errored after %s, error=%v.\n", time.Since(dbPingStart), dbErr.Error())
|
||||
return str.String(), dbErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(&str, "Probe db: Ping succeeded in %s.\n", time.Since(dbPingStart))
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
func probeDatabaseNextHealthSequence(ctx context.Context, database *sql.DB) (string, error) {
|
||||
var str strings.Builder
|
||||
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
|
||||
dbWriteStart := time.Now()
|
||||
|
||||
var seqVal int
|
||||
var dbWriteWg sync.WaitGroup
|
||||
var dbErr error
|
||||
|
||||
dbWriteWg.Add(1)
|
||||
go func() {
|
||||
dbErr = database.QueryRowContext(ctx, "SELECT nextval('seq_health');").Scan(&seqVal)
|
||||
dbWriteWg.Done()
|
||||
}()
|
||||
|
||||
if err := util.WaitTimeout(&dbWriteWg, time.Until(ctxDeadline)); err != nil {
|
||||
fmt.Fprintf(&str, "Probe db: Next health sequence deadline after %s, error=%v.\n", time.Since(dbWriteStart), err.Error())
|
||||
return str.String(), err
|
||||
}
|
||||
|
||||
if dbErr != nil {
|
||||
fmt.Fprintf(&str, "Probe db: Next health sequence errored after %s, error=%v.\n", time.Since(dbWriteStart), dbErr.Error())
|
||||
return str.String(), dbErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(&str, "Probe db: Next health sequence succeeded in %s, seq_health=%v.\n", time.Since(dbWriteStart), seqVal)
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
func probePathWriteablePermission(ctx context.Context, writeablePath string) (string, error) {
|
||||
var str strings.Builder
|
||||
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
|
||||
fsWriteStart := time.Now()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': W_OK check cancelled after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), ctx.Err())
|
||||
return str.String(), ctx.Err()
|
||||
}
|
||||
|
||||
var fsWriteWg sync.WaitGroup
|
||||
var fsWriteErr error
|
||||
fsWriteWg.Add(1)
|
||||
go func(wp string) {
|
||||
fsWriteErr = unix.Access(wp, unix.W_OK)
|
||||
fsWriteWg.Done()
|
||||
}(writeablePath)
|
||||
|
||||
if err := util.WaitTimeout(&fsWriteWg, time.Until(ctxDeadline)); err != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': W_OK check deadline after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), err)
|
||||
return str.String(), err
|
||||
}
|
||||
|
||||
if fsWriteErr != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': W_OK check errored after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), fsWriteErr.Error())
|
||||
return str.String(), fsWriteErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(&str, "Probe path '%s': W_OK check succeeded in %s.\n", writeablePath, time.Since(fsWriteStart))
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
func probePathWriteableTouch(ctx context.Context, writeablePath string, touch string) (string, error) {
|
||||
var str strings.Builder
|
||||
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
|
||||
fsTouchStart := time.Now()
|
||||
fsTouchNameAbs := path.Join(writeablePath, touch)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': Touch cancelled after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), ctx.Err())
|
||||
return str.String(), ctx.Err()
|
||||
}
|
||||
|
||||
var fsTouchWg sync.WaitGroup
|
||||
var fsTouchErr error
|
||||
var fsTouchModTime time.Time
|
||||
fsTouchWg.Add(1)
|
||||
go func(tn string) {
|
||||
fsTouchModTime, fsTouchErr = util.TouchFile(tn)
|
||||
fsTouchWg.Done()
|
||||
}(fsTouchNameAbs)
|
||||
|
||||
if err := util.WaitTimeout(&fsTouchWg, time.Until(ctxDeadline)); err != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': Touch deadline after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), err)
|
||||
return str.String(), err
|
||||
}
|
||||
|
||||
if fsTouchErr != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': Touch errored after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), fsTouchErr.Error())
|
||||
return str.String(), fsTouchErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(&str, "Probe path '%s': Touch succeeded in %s, modTime=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), fsTouchModTime.Unix())
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
70
internal/api/handlers/common/probes_internal_test.go
Normal file
70
internal/api/handlers/common/probes_internal_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEnsureDeadline(t *testing.T) {
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithDeadline(t.Context(), deadline)
|
||||
defer cancel()
|
||||
|
||||
receivedDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
assert.Equal(t, deadline, receivedDeadline)
|
||||
}
|
||||
|
||||
func TestDummyDeadlineWithinOneSec(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
receivedDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
assert.WithinDuration(t, time.Now().Add(1*time.Second), receivedDeadline, 100*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestProbeDatabasePingableDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
_, err := probeDatabasePingable(ctx, &sql.DB{})
|
||||
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
|
||||
}
|
||||
|
||||
func TestProbeDatabaseNextHealthSequenceDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
_, err := probeDatabaseNextHealthSequence(ctx, &sql.DB{})
|
||||
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
|
||||
}
|
||||
|
||||
func TestProbePathWriteablePermissionContextDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
_, err := probePathWriteablePermission(ctx, "/any/thing")
|
||||
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
|
||||
}
|
||||
|
||||
func TestProbePathWriteableTouchContextDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
_, err := probePathWriteableTouch(ctx, "/any/thing", ".touch")
|
||||
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
|
||||
}
|
||||
|
||||
func TestProbePathWriteableTouchInaccessable(t *testing.T) {
|
||||
_, err := probePathWriteableTouch(t.Context(), "/this/path/does/not/exist", ".touch")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
5
internal/api/handlers/constants/constants.go
Normal file
5
internal/api/handlers/constants/constants.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
RegistrationTokenParam = "registrationToken"
|
||||
)
|
||||
35
internal/api/handlers/handlers.go
Normal file
35
internal/api/handlers/handlers.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Code generated by go run -tags scripts scripts/handlers/gen_handlers.go; DO NOT EDIT.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/common"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/push"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/wellknown"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func AttachAllRoutes(s *api.Server) {
|
||||
// attach our routes
|
||||
s.Router.Routes = []*echo.Route{
|
||||
auth.DeleteUserAccountRoute(s),
|
||||
auth.GetCompleteRegisterRoute(s),
|
||||
auth.GetUserInfoRoute(s),
|
||||
auth.PostChangePasswordRoute(s),
|
||||
auth.PostCompleteRegisterRoute(s),
|
||||
auth.PostForgotPasswordCompleteRoute(s),
|
||||
auth.PostForgotPasswordRoute(s),
|
||||
auth.PostLoginRoute(s),
|
||||
auth.PostLogoutRoute(s),
|
||||
auth.PostRefreshRoute(s),
|
||||
auth.PostRegisterRoute(s),
|
||||
common.GetHealthyRoute(s),
|
||||
common.GetReadyRoute(s),
|
||||
common.GetSwaggerRoute(s),
|
||||
common.GetVersionRoute(s),
|
||||
push.PutUpdatePushTokenRoute(s),
|
||||
wellknown.GetAndroidDigitalAssetLinksRoute(s),
|
||||
wellknown.GetAppleAppSiteAssociationRoute(s),
|
||||
}
|
||||
}
|
||||
44
internal/api/handlers/push/put_update_push_token.go
Normal file
44
internal/api/handlers/push/put_update_push_token.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package push
|
||||
|
||||
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/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PutUpdatePushTokenRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Push.PUT("/token", putUpdatePushTokenHandler(s))
|
||||
}
|
||||
|
||||
func putUpdatePushTokenHandler(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.PutUpdatePushTokenPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := s.Local.UpdatePushToken(ctx, dto.UpdatePushTokenRequest{
|
||||
User: *user,
|
||||
Token: swag.StringValue(body.NewToken),
|
||||
Provider: swag.StringValue(body.Provider),
|
||||
ExistingToken: null.StringFromPtr(body.OldToken),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to update push token")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.String(http.StatusOK, "Success")
|
||||
}
|
||||
}
|
||||
169
internal/api/handlers/push/put_update_push_token_test.go
Normal file
169
internal/api/handlers/push/put_update_push_token_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package push_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/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPutUpdatePushTokenSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
//nolint:gosec
|
||||
testToken := "869f6deb-73e6-4691-9d40-2a2a794006cf"
|
||||
testProvider := models.ProviderTypeFCM
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"newToken": testToken,
|
||||
"provider": testProvider,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, newToken.ID)
|
||||
assert.Equal(t, testToken, newToken.Token)
|
||||
assert.Equal(t, testProvider, newToken.Provider)
|
||||
assert.Equal(t, fix.User1.ID, newToken.UserID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPutUpdatePushTokenSuccessWithOldToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
//nolint:gosec
|
||||
oldToken := "6803ccb4-c91d-47b2-960e-291afa5e29cd"
|
||||
|
||||
oldPushToken := models.PushToken{
|
||||
Token: oldToken,
|
||||
Provider: models.ProviderTypeFCM,
|
||||
UserID: fix.User1.ID,
|
||||
}
|
||||
err := oldPushToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec
|
||||
testToken := "af55b6cf-1fb0-4bb7-960c-25268a5ce7c3"
|
||||
testProvider := models.ProviderTypeFCM
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"newToken": testToken,
|
||||
"provider": testProvider,
|
||||
"oldToken": oldToken,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, newToken.ID)
|
||||
assert.Equal(t, testToken, newToken.Token)
|
||||
assert.Equal(t, testProvider, newToken.Provider)
|
||||
assert.Equal(t, fix.User1.ID, newToken.UserID)
|
||||
|
||||
err = oldPushToken.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPutUpdatePushTokenWithDuplicateToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
//nolint:gosec
|
||||
oldToken := "6803ccb4-c91d-47b2-960e-291afa5e29cd"
|
||||
|
||||
oldPushToken := models.PushToken{
|
||||
Token: oldToken,
|
||||
Provider: models.ProviderTypeFCM,
|
||||
UserID: fix.User1.ID,
|
||||
}
|
||||
err := oldPushToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
testProvider := models.ProviderTypeFCM
|
||||
payload := test.GenericPayload{
|
||||
"newToken": oldToken,
|
||||
"provider": testProvider,
|
||||
"oldToken": oldToken,
|
||||
}
|
||||
|
||||
oldCnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrConflictPushToken)
|
||||
|
||||
err = oldPushToken.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldCnt, cnt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPutUpdatePushTokenWithOldTokenNotfound(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
//nolint:gosec
|
||||
oldToken := "cc08624a-b40d-4b8e-bbfe-f62aabb47592"
|
||||
|
||||
oldPushToken := models.PushToken{
|
||||
Token: oldToken,
|
||||
Provider: models.ProviderTypeFCM,
|
||||
UserID: fix.User1.ID,
|
||||
}
|
||||
err := oldPushToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
oldCnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec
|
||||
testToken := "8e4ad85f-cbb6-4ef3-a455-d9d8bd8917b3"
|
||||
testProvider := models.ProviderTypeFCM
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"newToken": testToken,
|
||||
"provider": testProvider,
|
||||
"oldToken": "3199aa21-eb41-47dd-9287-338e9e88a5ae",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrNotFoundOldPushToken)
|
||||
|
||||
newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, newToken.ID)
|
||||
assert.Equal(t, testToken, newToken.Token)
|
||||
assert.Equal(t, testProvider, newToken.Provider)
|
||||
assert.Equal(t, fix.User1.ID, newToken.UserID)
|
||||
|
||||
err = oldPushToken.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldCnt+1, cnt)
|
||||
})
|
||||
}
|
||||
23
internal/api/handlers/wellknown/get_android_assetlinks.go
Normal file
23
internal/api/handlers/wellknown/get_android_assetlinks.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package wellknown
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetAndroidDigitalAssetLinksRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.WellKnown.GET("/assetlinks.json", getAndroidDigitalAssetLinksHandler(s))
|
||||
}
|
||||
|
||||
func getAndroidDigitalAssetLinksHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if s.Config.Paths.AndroidAssetlinksFile == "" {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
|
||||
c.Response().Header().Set("Content-Type", "application/json")
|
||||
|
||||
return c.File(s.Config.Paths.AndroidAssetlinksFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package wellknown_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"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/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func TestGetAndroidWellKnown(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Paths.AndroidAssetlinksFile = filepath.Join(util.GetProjectRootDir(), "test", "testdata", "android-assetlinks.json")
|
||||
|
||||
testGetWellKnown(t, config, "/.well-known/assetlinks.json")
|
||||
}
|
||||
|
||||
func TestGetAndroidWellKnownNotFound(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Paths.AndroidAssetlinksFile = ""
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/.well-known/assetlinks.json", nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrNotFound))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package wellknown
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetAppleAppSiteAssociationRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.WellKnown.GET("/apple-app-site-association", getAppleAppSiteAssociationHandler(s))
|
||||
}
|
||||
|
||||
func getAppleAppSiteAssociationHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if s.Config.Paths.AppleAppSiteAssociationFile == "" {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
|
||||
return c.File(s.Config.Paths.AppleAppSiteAssociationFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package wellknown_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"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/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testGetWellKnown(t *testing.T, config config.Server, path string) {
|
||||
t.Helper()
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", path, nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
result, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.Snapshoter.SaveString(t, string(result))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAppleWellKnown(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Paths.AppleAppSiteAssociationFile = filepath.Join(util.GetProjectRootDir(), "test", "testdata", "apple-app-site-association.json")
|
||||
|
||||
testGetWellKnown(t, config, "/.well-known/apple-app-site-association")
|
||||
}
|
||||
|
||||
func TestGetAppleWellKnownNotFound(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Paths.AppleAppSiteAssociationFile = ""
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/.well-known/apple-app-site-association", nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrNotFound))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user