(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))
|
||||
})
|
||||
}
|
||||
16
internal/api/httperrors/auth.go
Normal file
16
internal/api/httperrors/auth.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package httperrors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrForbiddenUserDeactivated = NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeUSERDEACTIVATED, "User account is deactivated")
|
||||
ErrBadRequestInvalidPassword = NewHTTPErrorWithDetail(http.StatusBadRequest, types.PublicHTTPErrorTypeINVALIDPASSWORD, "The password provided was invalid", "Password was either too weak or did not match other criteria")
|
||||
ErrForbiddenNotLocalUser = NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeNOTLOCALUSER, "User account is not valid for local authentication")
|
||||
ErrNotFoundTokenNotFound = NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeTOKENNOTFOUND, "Provided token was not found")
|
||||
ErrConflictTokenExpired = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypeTOKENEXPIRED, "Provided token has expired and is no longer valid")
|
||||
ErrConflictUserAlreadyExists = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypeUSERALREADYEXISTS, "User with given username already exists")
|
||||
)
|
||||
11
internal/api/httperrors/common.go
Normal file
11
internal/api/httperrors/common.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package httperrors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadRequestZeroFileSize = NewHTTPError(http.StatusBadRequest, types.PublicHTTPErrorTypeZEROFILESIZE, "File size of 0 is not supported.")
|
||||
)
|
||||
147
internal/api/httperrors/error.go
Normal file
147
internal/api/httperrors/error.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package httperrors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Payload in accordance with RFC 7807 (Problem Details for HTTP APIs) with the exception of the type
|
||||
// value not being represented by a URI. https://tools.ietf.org/html/rfc7807 @ 2020-04-27T15:44:37Z
|
||||
|
||||
type HTTPError struct {
|
||||
types.PublicHTTPError
|
||||
Internal error `json:"-"`
|
||||
AdditionalData map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
type HTTPValidationError struct {
|
||||
types.PublicHTTPValidationError
|
||||
Internal error `json:"-"`
|
||||
AdditionalData map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
func NewHTTPError(code int, errorType types.PublicHTTPErrorType, title string) *HTTPError {
|
||||
return &HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(code)),
|
||||
Type: errorType.Pointer(),
|
||||
Title: swag.String(title),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewHTTPErrorWithDetail(code int, errorType types.PublicHTTPErrorType, title string, detail string) *HTTPError {
|
||||
return &HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(code)),
|
||||
Type: errorType.Pointer(),
|
||||
Title: swag.String(title),
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewFromEcho(e *echo.HTTPError) *HTTPError {
|
||||
return NewHTTPError(e.Code, types.PublicHTTPErrorTypeGeneric, http.StatusText(e.Code))
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
var builder strings.Builder
|
||||
|
||||
fmt.Fprintf(&builder, "HTTPError %d (%s): %s", *e.Code, *e.Type, *e.Title)
|
||||
|
||||
if len(e.Detail) > 0 {
|
||||
fmt.Fprintf(&builder, " - %s", e.Detail)
|
||||
}
|
||||
if e.Internal != nil {
|
||||
fmt.Fprintf(&builder, ", %v", e.Internal)
|
||||
}
|
||||
if len(e.AdditionalData) > 0 {
|
||||
keys := make([]string, 0, len(e.AdditionalData))
|
||||
for k := range e.AdditionalData {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
builder.WriteString(". Additional: ")
|
||||
for i, k := range keys {
|
||||
fmt.Fprintf(&builder, "%s=%v", k, e.AdditionalData[k])
|
||||
if i < len(keys)-1 {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func NewHTTPValidationError(code int, errorType types.PublicHTTPErrorType, title string, validationErrors []*types.HTTPValidationErrorDetail) *HTTPValidationError {
|
||||
return &HTTPValidationError{
|
||||
PublicHTTPValidationError: types.PublicHTTPValidationError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(code)),
|
||||
Type: errorType.Pointer(),
|
||||
Title: swag.String(title),
|
||||
},
|
||||
ValidationErrors: validationErrors,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewHTTPValidationErrorWithDetail(code int, errorType types.PublicHTTPErrorType, title string, validationErrors []*types.HTTPValidationErrorDetail, detail string) *HTTPValidationError {
|
||||
return &HTTPValidationError{
|
||||
PublicHTTPValidationError: types.PublicHTTPValidationError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(code)),
|
||||
Type: errorType.Pointer(),
|
||||
Title: swag.String(title),
|
||||
Detail: detail,
|
||||
},
|
||||
ValidationErrors: validationErrors,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HTTPValidationError) Error() string {
|
||||
var builder strings.Builder
|
||||
|
||||
fmt.Fprintf(&builder, "HTTPValidationError %d (%s): %s", *e.Code, *e.Type, *e.Title)
|
||||
|
||||
if len(e.Detail) > 0 {
|
||||
fmt.Fprintf(&builder, " - %s", e.Detail)
|
||||
}
|
||||
if e.Internal != nil {
|
||||
fmt.Fprintf(&builder, ", %v", e.Internal)
|
||||
}
|
||||
if len(e.AdditionalData) > 0 {
|
||||
keys := make([]string, 0, len(e.AdditionalData))
|
||||
for k := range e.AdditionalData {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
builder.WriteString(". Additional: ")
|
||||
for i, k := range keys {
|
||||
fmt.Fprintf(&builder, "%s=%v", k, e.AdditionalData[k])
|
||||
if i < len(keys)-1 {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder.WriteString(" - Validation: ")
|
||||
for i, ve := range e.ValidationErrors {
|
||||
fmt.Fprintf(&builder, "%s (in %s): %s", *ve.Key, *ve.In, *ve.Error)
|
||||
if i < len(e.ValidationErrors)-1 {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
80
internal/api/httperrors/error_test.go
Normal file
80
internal/api/httperrors/error_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package httperrors_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHTTPErrorSimple(t *testing.T) {
|
||||
err := httperrors.NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusNotFound))
|
||||
require.Equal(t, "HTTPError 404 (generic): Not Found", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPErrorDetail(t *testing.T) {
|
||||
err := httperrors.NewHTTPErrorWithDetail(http.StatusNotFound, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusNotFound), "ToS violation")
|
||||
require.Equal(t, "HTTPError 404 (generic): Not Found - ToS violation", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPErrorInternalError(t *testing.T) {
|
||||
err := httperrors.NewHTTPError(http.StatusInternalServerError, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusInternalServerError))
|
||||
|
||||
err.Internal = sql.ErrConnDone
|
||||
|
||||
require.Equal(t, "HTTPError 500 (generic): Internal Server Error, sql: connection is already closed", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPErrorAdditionalData(t *testing.T) {
|
||||
err := httperrors.NewHTTPError(http.StatusInternalServerError, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusInternalServerError))
|
||||
|
||||
err.AdditionalData = map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
}
|
||||
|
||||
require.Equal(t, "HTTPError 500 (generic): Internal Server Error. Additional: key1=value1, key2=value2", err.Error())
|
||||
}
|
||||
|
||||
var valErrs = append(make([]*types.HTTPValidationErrorDetail, 0, 2), &types.HTTPValidationErrorDetail{
|
||||
Key: swag.String("test1"),
|
||||
In: swag.String("body.test1"),
|
||||
Error: swag.String("ValidationError"),
|
||||
}, &types.HTTPValidationErrorDetail{
|
||||
Key: swag.String("test2"),
|
||||
In: swag.String("body.test2"),
|
||||
Error: swag.String("Validation Error"),
|
||||
})
|
||||
|
||||
func TestHTTPValidationErrorSimple(t *testing.T) {
|
||||
err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
|
||||
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPValidationErrorDetail(t *testing.T) {
|
||||
err := httperrors.NewHTTPValidationErrorWithDetail(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs, "Did API spec change?")
|
||||
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request - Did API spec change? - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPValidationErrorInternalError(t *testing.T) {
|
||||
err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
|
||||
|
||||
err.Internal = sql.ErrConnDone
|
||||
|
||||
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request, sql: connection is already closed - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPValidationErrorAdditionalData(t *testing.T) {
|
||||
err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
|
||||
|
||||
err.AdditionalData = map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
}
|
||||
|
||||
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request. Additional: key1=value1, key2=value2 - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
|
||||
}
|
||||
12
internal/api/httperrors/push.go
Normal file
12
internal/api/httperrors/push.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package httperrors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConflictPushToken = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypePUSHTOKENALREADYEXISTS, "The given token already exists.")
|
||||
ErrNotFoundOldPushToken = NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeOLDPUSHTOKENNOTFOUND, "The old push token does not exists. The new token was saved.")
|
||||
)
|
||||
390
internal/api/middleware/auth.go
Normal file
390
internal/api/middleware/auth.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/mapper"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadRequestMalformedToken = httperrors.NewHTTPError(http.StatusBadRequest, types.PublicHTTPErrorTypeMALFORMEDTOKEN, "Auth token is malformed")
|
||||
ErrUnauthorizedLastAuthenticatedAtExceeded = httperrors.NewHTTPError(http.StatusUnauthorized, types.PublicHTTPErrorTypeLASTAUTHENTICATEDATEXCEEDED, "LastAuthenticatedAt timestamp exceeds threshold, re-authentication required")
|
||||
ErrForbiddenMissingScopes = httperrors.NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeMISSINGSCOPES, "User is missing required scopes")
|
||||
ErrAuthTokenValidationFailed = errors.New("auth token validation failed")
|
||||
)
|
||||
|
||||
// AuthMode controls the type of authentication check performed for a specific route or group
|
||||
type AuthMode int
|
||||
|
||||
const (
|
||||
// AuthModeRequired requires an auth token to be present and valid in order to access the route or group
|
||||
AuthModeRequired AuthMode = iota
|
||||
// AuthModeSecure requires an auth token to be present and for the user to have recently re-confirmed their authentication in order to access the route or group
|
||||
AuthModeSecure
|
||||
// AuthModeOptional does not require an auth token to be present, however if it is, it must be valid in order to access the route or group
|
||||
AuthModeOptional
|
||||
// AuthModeTry does not require an auth token to be present in order to access the route or group and will process the request even if an invalid one has been provided
|
||||
AuthModeTry
|
||||
// AuthModeNone does not require an auth token to be present in order to access the route or group and will not attempt to parse any authentication provided
|
||||
AuthModeNone
|
||||
)
|
||||
|
||||
func (m AuthMode) String() string {
|
||||
switch m {
|
||||
case AuthModeRequired:
|
||||
return "required"
|
||||
case AuthModeSecure:
|
||||
return "secure"
|
||||
case AuthModeOptional:
|
||||
return "optional"
|
||||
case AuthModeTry:
|
||||
return "try"
|
||||
case AuthModeNone:
|
||||
return "none"
|
||||
default:
|
||||
return fmt.Sprintf("unknown (%d)", m)
|
||||
}
|
||||
}
|
||||
|
||||
type AuthFailureMode int
|
||||
|
||||
const (
|
||||
// AuthFailureModeUnauthorized returns a 401 Unauthorized response on missing or invalid authentication
|
||||
AuthFailureModeUnauthorized AuthFailureMode = iota
|
||||
// AuthFailureModeNotFound returns a 404 Not Found response on missing or invalid authentication
|
||||
AuthFailureModeNotFound
|
||||
)
|
||||
|
||||
func (m AuthFailureMode) String() string {
|
||||
switch m {
|
||||
case AuthFailureModeUnauthorized:
|
||||
return "unauthorized"
|
||||
case AuthFailureModeNotFound:
|
||||
return "not_found"
|
||||
default:
|
||||
return fmt.Sprintf("unknown (%d)", m)
|
||||
}
|
||||
}
|
||||
|
||||
func (m AuthFailureMode) Error() error {
|
||||
switch m {
|
||||
case AuthFailureModeUnauthorized:
|
||||
return echo.ErrUnauthorized
|
||||
case AuthFailureModeNotFound:
|
||||
return echo.ErrNotFound
|
||||
default:
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
type AuthTokenSource int
|
||||
|
||||
const (
|
||||
// AuthTokenSourceHeader retrieves the auth token from a header, specified by TokenSourceKey
|
||||
AuthTokenSourceHeader AuthTokenSource = iota
|
||||
// AuthTokenSourceQuery retrieves the auth token from a query parameter, specified by TokenSourceKey
|
||||
AuthTokenSourceQuery
|
||||
// AuthTokenSourceForm retrieves the auth token from a form parameter, specified by TokenSourceKey
|
||||
AuthTokenSourceForm
|
||||
)
|
||||
|
||||
func (s AuthTokenSource) String() string {
|
||||
switch s {
|
||||
case AuthTokenSourceHeader:
|
||||
return "header"
|
||||
case AuthTokenSourceQuery:
|
||||
return "query"
|
||||
case AuthTokenSourceForm:
|
||||
return "form"
|
||||
default:
|
||||
return fmt.Sprintf("unknown (%d)", s)
|
||||
}
|
||||
}
|
||||
|
||||
func (s AuthTokenSource) Extract(c echo.Context, key string, scheme string) (string, bool) {
|
||||
var token string
|
||||
|
||||
switch s {
|
||||
case AuthTokenSourceHeader:
|
||||
token = c.Request().Header.Get(key)
|
||||
case AuthTokenSourceForm:
|
||||
token = c.FormValue(key)
|
||||
case AuthTokenSourceQuery:
|
||||
token = c.QueryParam(key)
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
||||
if len(token) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
lenScheme := len(scheme)
|
||||
if lenScheme == 0 {
|
||||
return token, true
|
||||
}
|
||||
|
||||
if len(token) < lenScheme+1 {
|
||||
return "", true
|
||||
}
|
||||
|
||||
if token[:lenScheme] != scheme {
|
||||
return "", true
|
||||
}
|
||||
|
||||
return token[lenScheme+1:], true
|
||||
}
|
||||
|
||||
type AuthTokenFormatValidator func(string) bool
|
||||
|
||||
func DefaultAuthTokenFormatValidator(token string) bool {
|
||||
return strfmt.IsUUID4(token)
|
||||
}
|
||||
|
||||
type AuthTokenValidator func(c echo.Context, config AuthConfig, token string) (auth.Result, error)
|
||||
|
||||
func DefaultAuthTokenValidator(c echo.Context, config AuthConfig, token string) (auth.Result, error) {
|
||||
accessToken, err := models.AccessTokens(
|
||||
models.AccessTokenWhere.Token.EQ(token),
|
||||
qm.Load(qm.Rels(models.AccessTokenRels.User, models.UserRels.AppUserProfile)),
|
||||
).One(c.Request().Context(), config.S.DB)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Trace().Err(err).Msg("Access token not found in database")
|
||||
return auth.Result{}, ErrAuthTokenValidationFailed
|
||||
}
|
||||
|
||||
log.Error().Err(err).Msg("Failed to query for access token in database, aborting request")
|
||||
return auth.Result{}, echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return auth.Result{
|
||||
Token: accessToken.Token,
|
||||
User: mapper.LocalUserToDTO(accessToken.R.User).Ptr(),
|
||||
ValidUntil: accessToken.ValidUntil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultAuthConfig = AuthConfig{
|
||||
Mode: AuthModeRequired,
|
||||
FailureMode: AuthFailureModeUnauthorized,
|
||||
TokenSource: AuthTokenSourceHeader,
|
||||
TokenSourceKey: echo.HeaderAuthorization,
|
||||
Scheme: "Bearer",
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
FormatValidator: DefaultAuthTokenFormatValidator,
|
||||
TokenValidator: DefaultAuthTokenValidator,
|
||||
Scopes: []string{auth.ScopeApp.String()},
|
||||
}
|
||||
)
|
||||
|
||||
type AuthConfig struct {
|
||||
S *api.Server // API server used for database and service access
|
||||
Mode AuthMode // Controls type of authentication required (default: AuthModeRequired)
|
||||
FailureMode AuthFailureMode // Controls response on auth failure (default: AuthFailureModeUnauthorized)
|
||||
TokenSource AuthTokenSource // Sets source of auth token (default: AuthTokenSourceHeader)
|
||||
TokenSourceKey string // Sets key for auth token source lookup (default: "Authorization")
|
||||
Scheme string // Sets required token scheme (default: "Bearer")
|
||||
Skipper middleware.Skipper // Controls skipping of certain routes (default: no skipped routes)
|
||||
FormatValidator AuthTokenFormatValidator // Validates the format of the token retrieved
|
||||
TokenValidator AuthTokenValidator // Validates token retrieved and returns associated user (default: performs lookup in access_tokens table)
|
||||
Scopes []string // List of scopes required to access endpoint (default: none required)
|
||||
}
|
||||
|
||||
func (c AuthConfig) CheckLastAuthenticatedAt(user *dto.User) bool {
|
||||
if c.Mode != AuthModeSecure {
|
||||
return true
|
||||
}
|
||||
|
||||
if !user.LastAuthenticatedAt.Valid {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Since(user.LastAuthenticatedAt.Time).Seconds() <= c.S.Config.Auth.LastAuthenticatedAtThreshold.Seconds()
|
||||
}
|
||||
|
||||
func (c AuthConfig) CheckUserScopes(user *dto.User) bool {
|
||||
if len(c.Scopes) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(user.Scopes) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, scope := range c.Scopes {
|
||||
for _, userScope := range user.Scopes {
|
||||
if scope == userScope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func Auth(s *api.Server) echo.MiddlewareFunc {
|
||||
c := DefaultAuthConfig
|
||||
c.S = s
|
||||
return AuthWithConfig(c)
|
||||
}
|
||||
|
||||
func AuthWithConfig(config AuthConfig) echo.MiddlewareFunc {
|
||||
if config.S == nil {
|
||||
panic("auth middleware: server is required")
|
||||
}
|
||||
|
||||
if len(config.TokenSourceKey) == 0 {
|
||||
config.TokenSourceKey = DefaultAuthConfig.TokenSourceKey
|
||||
}
|
||||
|
||||
if len(config.Scheme) == 0 {
|
||||
config.Scheme = DefaultAuthConfig.Scheme
|
||||
}
|
||||
|
||||
if config.Skipper == nil {
|
||||
config.Skipper = DefaultAuthConfig.Skipper
|
||||
}
|
||||
|
||||
if config.FormatValidator == nil {
|
||||
config.FormatValidator = DefaultAuthConfig.FormatValidator
|
||||
}
|
||||
|
||||
if config.TokenValidator == nil {
|
||||
config.TokenValidator = DefaultAuthConfig.TokenValidator
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
log := util.LogFromEchoContext(c).With().Str("middleware", "auth").Str("auth_mode", config.Mode.String()).Logger()
|
||||
|
||||
if config.Mode == AuthModeNone {
|
||||
log.Trace().Msg("No authentication required, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if config.Skipper(c) {
|
||||
log.Trace().Msg("Skipping auth middleware, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
user := auth.UserFromEchoContext(c)
|
||||
if user != nil {
|
||||
if !config.CheckLastAuthenticatedAt(user) {
|
||||
log.Trace().
|
||||
Time("last_authenticated_at", user.LastAuthenticatedAt.Time).
|
||||
Dur("last_authenticated_at_threshold", config.S.Config.Auth.LastAuthenticatedAtThreshold).
|
||||
Msg("Authentication already performed, but last authenticated at time exceeds threshold, rejecting request")
|
||||
return ErrUnauthorizedLastAuthenticatedAtExceeded
|
||||
}
|
||||
|
||||
if !config.CheckUserScopes(user) {
|
||||
log.Trace().
|
||||
Strs("scopes", config.Scopes).
|
||||
Strs("user_scopes", user.Scopes).
|
||||
Msg("Authentication already performed, but user does not have required scopes, rejecting request")
|
||||
return ErrForbiddenMissingScopes
|
||||
}
|
||||
|
||||
log.Trace().Msg("Authentication already performed, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
token, exists := config.TokenSource.Extract(c, config.TokenSourceKey, config.Scheme)
|
||||
if len(token) == 0 {
|
||||
if config.Mode == AuthModeRequired || config.Mode == AuthModeSecure || (exists && config.Mode == AuthModeOptional) {
|
||||
log.Trace().Bool("token_exists", exists).Msg("Request has missing or malformed token, rejecting")
|
||||
return config.FailureMode.Error()
|
||||
}
|
||||
|
||||
log.Trace().Bool("token_exists", exists).Msg("Request does not have valid token, but auth mode permits access, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if !config.FormatValidator(token) {
|
||||
if config.Mode == AuthModeRequired || config.Mode == AuthModeSecure || config.Mode == AuthModeOptional {
|
||||
log.Trace().Msg("Request has malformed token, rejecting")
|
||||
return ErrBadRequestMalformedToken
|
||||
}
|
||||
|
||||
log.Trace().Msg("Request have malformed token, but auth mode permits access, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
res, err := config.TokenValidator(c, config, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuthTokenValidationFailed) {
|
||||
if config.Mode == AuthModeTry {
|
||||
log.Trace().Msg("Auth token validation failed, but auth mode permits access, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
log.Trace().Msg("Auth token validation failed, rejecting request")
|
||||
return config.FailureMode.Error()
|
||||
}
|
||||
|
||||
log.Trace().Err(err).Msg("Failed to validate auth token, aborting request")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
user = res.User
|
||||
|
||||
if res.ValidUntil.IsZero() {
|
||||
log.Trace().Str("user_id", user.ID).Msg("Auth token has no expiry, allowing request")
|
||||
} else if config.S.Clock.Now().After(res.ValidUntil) {
|
||||
if config.Mode == AuthModeTry {
|
||||
log.Trace().Time("valid_until", res.ValidUntil).Str("user_id", user.ID).Msg("Auth token is expired, but auth mode permits access, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
log.Trace().Time("valid_until", res.ValidUntil).Str("user_id", user.ID).Msg("Auth token is expired, rejecting request")
|
||||
return config.FailureMode.Error()
|
||||
}
|
||||
|
||||
// ! User has been explicitly deactivated - we do not allow access here, even with AuthModeTry
|
||||
if !user.IsActive {
|
||||
log.Trace().Str("user_id", user.ID).Msg("User is deactivated, rejecting request")
|
||||
return httperrors.ErrForbiddenUserDeactivated
|
||||
}
|
||||
|
||||
if !config.CheckLastAuthenticatedAt(user) {
|
||||
log.Trace().
|
||||
Time("last_authenticated_at", user.LastAuthenticatedAt.Time).
|
||||
Dur("last_authenticated_at_threshold", config.S.Config.Auth.LastAuthenticatedAtThreshold).
|
||||
Msg("Authentication already performed, but last authenticated at time exceeds threshold, rejecting request")
|
||||
return ErrUnauthorizedLastAuthenticatedAtExceeded
|
||||
}
|
||||
|
||||
if !config.CheckUserScopes(user) {
|
||||
log.Trace().
|
||||
Strs("scopes", config.Scopes).
|
||||
Strs("user_scopes", user.Scopes).
|
||||
Msg("Authentication already performed, but user does not have required scopes, rejecting request")
|
||||
return ErrForbiddenMissingScopes
|
||||
}
|
||||
|
||||
auth.EnrichEchoContextWithCredentials(c, res)
|
||||
|
||||
log.Trace().Str("user_id", user.ID).Msg("Auth token is valid, allowing request")
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
55
internal/api/middleware/cache_control.go
Normal file
55
internal/api/middleware/cache_control.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultCacheControlConfig = CacheControlConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
}
|
||||
)
|
||||
|
||||
type CacheControlConfig struct {
|
||||
Skipper middleware.Skipper
|
||||
}
|
||||
|
||||
func CacheControl() echo.MiddlewareFunc {
|
||||
return CacheControlWithConfig(DefaultCacheControlConfig)
|
||||
}
|
||||
|
||||
func CacheControlWithConfig(config CacheControlConfig) echo.MiddlewareFunc {
|
||||
if config.Skipper == nil {
|
||||
config.Skipper = DefaultCacheControlConfig.Skipper
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if config.Skipper(c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
cacheControl := c.Request().Header.Get(util.HTTPHeaderCacheControl)
|
||||
if len(cacheControl) > 0 {
|
||||
directive := util.ParseCacheControlHeader(cacheControl)
|
||||
|
||||
ctx := c.Request().Context()
|
||||
|
||||
l := util.LogFromContext(ctx).With().Str("cacheControl", directive.String()).Logger()
|
||||
ctx = l.WithContext(ctx)
|
||||
|
||||
ctx = context.WithValue(ctx, util.CTXKeyCacheControl, directive)
|
||||
|
||||
l.Trace().Msg("Setting cache control directive for request")
|
||||
|
||||
c.SetRequest(c.Request().WithContext(ctx))
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
334
internal/api/middleware/logger.go
Normal file
334
internal/api/middleware/logger.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// RequestBodyLogSkipper defines a function to skip logging certain request bodies.
|
||||
// Returning true skips logging the payload of the request.
|
||||
type RequestBodyLogSkipper func(req *http.Request) bool
|
||||
|
||||
// DefaultRequestBodyLogSkipper returns true for all requests with Content-Type
|
||||
// application/x-www-form-urlencoded or multipart/form-data as those might contain
|
||||
// binary or URL-encoded file uploads unfit for logging purposes.
|
||||
func DefaultRequestBodyLogSkipper(req *http.Request) bool {
|
||||
contentType := req.Header.Get(echo.HeaderContentType)
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, echo.MIMEApplicationForm),
|
||||
strings.HasPrefix(contentType, echo.MIMEMultipartForm):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ResponseBodyLogSkipper defines a function to skip logging certain response bodies.
|
||||
// Returning true skips logging the payload of the response.
|
||||
type ResponseBodyLogSkipper func(req *http.Request, res *echo.Response) bool
|
||||
|
||||
// DefaultResponseBodyLogSkipper returns false for all responses with Content-Type
|
||||
// application/json, preventing logging for all other types of payloads as those
|
||||
// might contain binary or URL-encoded data unfit for logging purposes.
|
||||
func DefaultResponseBodyLogSkipper(_ *http.Request, res *echo.Response) bool {
|
||||
contentType := res.Header().Get(echo.HeaderContentType)
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, echo.MIMEApplicationJSON):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// BodyLogReplacer defines a function to replace certain parts of a body before logging it,
|
||||
// mainly used to strip sensitive information from a request or response payload.
|
||||
// The []byte returned should contain a sanitized payload ready for logging.
|
||||
type BodyLogReplacer func(body []byte) []byte
|
||||
|
||||
// DefaultBodyLogReplacer returns the body received without any modifications.
|
||||
func DefaultBodyLogReplacer(body []byte) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
// HeaderLogReplacer defines a function to replace certain parts of a header before logging it,
|
||||
// mainly used to strip sensitive information from a request or response header.
|
||||
// The http.Header returned should be a sanitized copy of the original header as not to modify
|
||||
// the request or response while logging.
|
||||
type HeaderLogReplacer func(header http.Header) http.Header
|
||||
|
||||
// DefaultHeaderLogReplacer replaces all Authorization, X-CSRF-Token and Proxy-Authorization
|
||||
// header entries with a redacted string, indicating their presence without revealing actual,
|
||||
// potentially sensitive values in the logs.
|
||||
func DefaultHeaderLogReplacer(headers http.Header) http.Header {
|
||||
sanitizedHeader := http.Header{}
|
||||
|
||||
for key, value := range headers {
|
||||
shouldRedact := strings.EqualFold(key, echo.HeaderAuthorization) ||
|
||||
strings.EqualFold(key, echo.HeaderXCSRFToken) ||
|
||||
strings.EqualFold(key, "Proxy-Authorization")
|
||||
|
||||
for _, v := range value {
|
||||
if shouldRedact {
|
||||
sanitizedHeader.Add(key, "*****REDACTED*****")
|
||||
} else {
|
||||
sanitizedHeader.Add(key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizedHeader
|
||||
}
|
||||
|
||||
// QueryLogReplacer defines a function to replace certain parts of a URL query before logging it,
|
||||
// mainly used to strip sensitive information from a request query.
|
||||
// The url.Values returned should be a sanitized copy of the original query as not to modify the
|
||||
// request while logging.
|
||||
type QueryLogReplacer func(query url.Values) url.Values
|
||||
|
||||
// DefaultQueryLogReplacer returns the query received without any modifications.
|
||||
func DefaultQueryLogReplacer(query url.Values) url.Values {
|
||||
return query
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultLoggerConfig = LoggerConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
Level: zerolog.DebugLevel,
|
||||
LogRequestBody: false,
|
||||
LogRequestHeader: false,
|
||||
LogRequestQuery: false,
|
||||
RequestBodyLogSkipper: DefaultRequestBodyLogSkipper,
|
||||
RequestBodyLogReplacer: DefaultBodyLogReplacer,
|
||||
RequestHeaderLogReplacer: DefaultHeaderLogReplacer,
|
||||
RequestQueryLogReplacer: DefaultQueryLogReplacer,
|
||||
LogResponseBody: false,
|
||||
LogResponseHeader: false,
|
||||
ResponseBodyLogSkipper: DefaultResponseBodyLogSkipper,
|
||||
ResponseBodyLogReplacer: DefaultBodyLogReplacer,
|
||||
}
|
||||
)
|
||||
|
||||
type LoggerConfig struct {
|
||||
Skipper middleware.Skipper
|
||||
Level zerolog.Level
|
||||
LogRequestBody bool
|
||||
LogRequestHeader bool
|
||||
LogRequestQuery bool
|
||||
LogCaller bool
|
||||
RequestBodyLogSkipper RequestBodyLogSkipper
|
||||
RequestBodyLogReplacer BodyLogReplacer
|
||||
RequestHeaderLogReplacer HeaderLogReplacer
|
||||
RequestQueryLogReplacer QueryLogReplacer
|
||||
LogResponseBody bool
|
||||
LogResponseHeader bool
|
||||
ResponseBodyLogSkipper ResponseBodyLogSkipper
|
||||
ResponseBodyLogReplacer BodyLogReplacer
|
||||
ResponseHeaderLogReplacer HeaderLogReplacer
|
||||
}
|
||||
|
||||
// Logger with default logger output and configuration
|
||||
func Logger() echo.MiddlewareFunc {
|
||||
return LoggerWithConfig(DefaultLoggerConfig, nil)
|
||||
}
|
||||
|
||||
// LoggerWithConfig returns a new MiddlewareFunc which creates a logger with the desired configuration.
|
||||
// If output is set to nil, the default output is used. If more output params are provided, the first is being used.
|
||||
func LoggerWithConfig(config LoggerConfig, output ...io.Writer) echo.MiddlewareFunc {
|
||||
if config.Skipper == nil {
|
||||
config.Skipper = DefaultLoggerConfig.Skipper
|
||||
}
|
||||
if config.RequestBodyLogSkipper == nil {
|
||||
config.RequestBodyLogSkipper = DefaultRequestBodyLogSkipper
|
||||
}
|
||||
if config.RequestBodyLogReplacer == nil {
|
||||
config.RequestBodyLogReplacer = DefaultBodyLogReplacer
|
||||
}
|
||||
if config.RequestHeaderLogReplacer == nil {
|
||||
config.RequestHeaderLogReplacer = DefaultHeaderLogReplacer
|
||||
}
|
||||
if config.RequestQueryLogReplacer == nil {
|
||||
config.RequestQueryLogReplacer = DefaultQueryLogReplacer
|
||||
}
|
||||
if config.ResponseBodyLogSkipper == nil {
|
||||
config.ResponseBodyLogSkipper = DefaultResponseBodyLogSkipper
|
||||
}
|
||||
if config.ResponseBodyLogReplacer == nil {
|
||||
config.ResponseBodyLogReplacer = DefaultBodyLogReplacer
|
||||
}
|
||||
if config.ResponseHeaderLogReplacer == nil {
|
||||
config.ResponseHeaderLogReplacer = DefaultHeaderLogReplacer
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if config.Skipper(c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
req := c.Request()
|
||||
res := c.Response()
|
||||
|
||||
requestID := req.Header.Get(echo.HeaderXRequestID)
|
||||
if len(requestID) == 0 {
|
||||
requestID = res.Header().Get(echo.HeaderXRequestID)
|
||||
}
|
||||
|
||||
contentLength := req.Header.Get(echo.HeaderContentLength)
|
||||
if len(contentLength) == 0 {
|
||||
contentLength = "0"
|
||||
}
|
||||
|
||||
logger := log.With().
|
||||
Dict("req", zerolog.Dict().
|
||||
Str("id", requestID).
|
||||
Str("host", req.Host).
|
||||
Str("method", req.Method).
|
||||
Str("url", req.URL.String()).
|
||||
Str("bytes_in", contentLength),
|
||||
).Logger()
|
||||
|
||||
if len(output) > 0 {
|
||||
logger = logger.Output(output[0])
|
||||
}
|
||||
|
||||
if config.LogCaller {
|
||||
// Caller uses https://pkg.go.dev/runtime#Caller underneath and might decrease the performance.
|
||||
logger = logger.With().Caller().Logger()
|
||||
}
|
||||
|
||||
le := logger.WithLevel(config.Level)
|
||||
req = req.WithContext(logger.WithContext(context.WithValue(req.Context(), util.CTXKeyRequestID, requestID)))
|
||||
|
||||
if config.LogRequestBody && !config.RequestBodyLogSkipper(req) {
|
||||
var reqBody []byte
|
||||
var err error
|
||||
if req.Body != nil {
|
||||
reqBody, err = io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to read body while logging request")
|
||||
return fmt.Errorf("failed to read body while logging request: %w", err)
|
||||
}
|
||||
|
||||
req.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||
}
|
||||
|
||||
le = le.Bytes("req_body", config.RequestBodyLogReplacer(reqBody))
|
||||
}
|
||||
if config.LogRequestHeader {
|
||||
header := zerolog.Dict()
|
||||
for k, v := range config.RequestHeaderLogReplacer(req.Header) {
|
||||
header.Strs(k, v)
|
||||
}
|
||||
|
||||
le = le.Dict("req_header", header)
|
||||
}
|
||||
if config.LogRequestQuery {
|
||||
query := zerolog.Dict()
|
||||
for k, v := range req.URL.Query() {
|
||||
query.Strs(k, v)
|
||||
}
|
||||
|
||||
le = le.Dict("req_query", query)
|
||||
}
|
||||
|
||||
le.Msg("Request received")
|
||||
|
||||
c.SetRequest(req)
|
||||
|
||||
var resBody bytes.Buffer
|
||||
if config.LogResponseBody {
|
||||
mw := io.MultiWriter(res.Writer, &resBody)
|
||||
writer := &bodyDumpResponseWriter{Writer: mw, ResponseWriter: res.Writer}
|
||||
res.Writer = writer
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
err := next(c)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
}
|
||||
stop := time.Now()
|
||||
|
||||
// Retrieve logger from context again since other middlewares might have enhanced it
|
||||
ll := util.LogFromEchoContext(c)
|
||||
lle := ll.WithLevel(config.Level).
|
||||
Dict("res", zerolog.Dict().
|
||||
Int("status", res.Status).
|
||||
Int64("bytes_out", res.Size).
|
||||
TimeDiff("duration_ms", stop, start).
|
||||
Err(err),
|
||||
)
|
||||
|
||||
if config.LogResponseBody && !config.ResponseBodyLogSkipper(req, res) {
|
||||
lle = lle.Bytes("res_body", config.ResponseBodyLogReplacer(resBody.Bytes()))
|
||||
}
|
||||
if config.LogResponseHeader {
|
||||
header := zerolog.Dict()
|
||||
for k, v := range config.ResponseHeaderLogReplacer(res.Header()) {
|
||||
header.Strs(k, v)
|
||||
}
|
||||
|
||||
lle = lle.Dict("res_header", header)
|
||||
}
|
||||
|
||||
lle.Msg("Response sent")
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type bodyDumpResponseWriter struct {
|
||||
io.Writer
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
func (w *bodyDumpResponseWriter) WriteHeader(code int) {
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *bodyDumpResponseWriter) Write(b []byte) (int, error) {
|
||||
n, err := w.Writer.Write(b)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write response body: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *bodyDumpResponseWriter) Flush() {
|
||||
flusher, ok := w.ResponseWriter.(http.Flusher)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("failed to get flusher as http.Flusher, got %T", w.ResponseWriter))
|
||||
}
|
||||
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
func (w *bodyDumpResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hijacker, ok := w.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("failed to get hijacker as http.Hijacker, got %T", w.ResponseWriter)
|
||||
}
|
||||
|
||||
conn, rw, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to hijack connection: %w", err)
|
||||
}
|
||||
|
||||
return conn, rw, nil
|
||||
}
|
||||
57
internal/api/middleware/logger_test.go
Normal file
57
internal/api/middleware/logger_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func logTestHandler(c echo.Context) error {
|
||||
log := util.LogFromEchoContext(c)
|
||||
log.Info().Msg("I'm here!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLogWithCaller(t *testing.T) {
|
||||
cfg := middleware.DefaultLoggerConfig
|
||||
cfg.LogCaller = false
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
loggerMW := middleware.LoggerWithConfig(cfg, rec)
|
||||
|
||||
e := echo.New()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
middlewareChain := loggerMW(logTestHandler)
|
||||
require.NoError(t, middlewareChain(c))
|
||||
|
||||
bufSize := 2000
|
||||
loggedData := make([]byte, bufSize)
|
||||
n, err := rec.Body.Read(loggedData)
|
||||
require.NoError(t, err)
|
||||
assert.Less(t, n, bufSize)
|
||||
// LogCaller was set to false
|
||||
assert.NotContains(t, string(loggedData), "caller")
|
||||
|
||||
// now log again with LogCaller set to true
|
||||
cfg.LogCaller = true
|
||||
loggerMW = middleware.LoggerWithConfig(cfg, rec)
|
||||
|
||||
rec.Flush()
|
||||
|
||||
middlewareChain = loggerMW(logTestHandler)
|
||||
require.NoError(t, middlewareChain(c))
|
||||
|
||||
n, err = rec.Body.Read(loggedData)
|
||||
require.NoError(t, err)
|
||||
assert.Less(t, n, bufSize)
|
||||
// logger_test.go:X should match the line where log.Info() is placed in the logTestHandler
|
||||
assert.Contains(t, string(loggedData[:n]), `"caller":"/app/internal/api/middleware/logger_test.go:17"`)
|
||||
}
|
||||
91
internal/api/middleware/no_cache.go
Normal file
91
internal/api/middleware/no_cache.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package middleware
|
||||
|
||||
// Based on https://github.com/LYY/echo-middleware (MIT License, https://github.com/LYY/echo-middleware/blob/master/LICENSE)
|
||||
// Ported from Goji's middleware, source:
|
||||
// https://github.com/zenazn/goji/tree/master/web/middleware (MIT License, https://github.com/zenazn/goji/blob/master/LICENSE)
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
middleware "github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
type (
|
||||
// NoCacheConfig defines the config for nocache middleware.
|
||||
NoCacheConfig struct {
|
||||
// Skipper defines a function to skip middleware.
|
||||
Skipper middleware.Skipper
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
// Unix epoch time
|
||||
epoch = time.Unix(0, 0).Format(time.RFC1123)
|
||||
|
||||
// Taken from https://github.com/mytrile/nocache
|
||||
noCacheHeaders = map[string]string{
|
||||
"Expires": epoch,
|
||||
"Cache-Control": "no-cache, private, max-age=0",
|
||||
"Pragma": "no-cache",
|
||||
"X-Accel-Expires": "0",
|
||||
}
|
||||
etagHeaders = []string{
|
||||
"ETag",
|
||||
"If-Modified-Since",
|
||||
"If-Match",
|
||||
"If-None-Match",
|
||||
"If-Range",
|
||||
"If-Unmodified-Since",
|
||||
}
|
||||
// DefaultNoCacheConfig is the default nocache middleware config.
|
||||
DefaultNoCacheConfig = NoCacheConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
}
|
||||
)
|
||||
|
||||
// NoCache is a simple piece of middleware that sets a number of HTTP headers to prevent
|
||||
// a router (or subrouter) from being cached by an upstream proxy and/or client.
|
||||
//
|
||||
// As per http://wiki.nginx.org/HttpProxyModule - NoCache sets:
|
||||
//
|
||||
// Expires: Thu, 01 Jan 1970 00:00:00 UTC
|
||||
// Cache-Control: no-cache, private, max-age=0
|
||||
// X-Accel-Expires: 0
|
||||
// Pragma: no-cache (for HTTP/1.0 proxies/clients)
|
||||
func NoCache() echo.MiddlewareFunc {
|
||||
return NoCacheWithConfig(DefaultNoCacheConfig)
|
||||
}
|
||||
|
||||
// NoCacheWithConfig returns a nocache middleware with config.
|
||||
func NoCacheWithConfig(config NoCacheConfig) echo.MiddlewareFunc {
|
||||
// Defaults
|
||||
if config.Skipper == nil {
|
||||
config.Skipper = DefaultNoCacheConfig.Skipper
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if config.Skipper(c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
req := c.Request()
|
||||
|
||||
// Delete any ETag headers that may have been set
|
||||
for _, v := range etagHeaders {
|
||||
if req.Header.Get(v) != "" {
|
||||
req.Header.Del(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Set our NoCache headers
|
||||
res := c.Response()
|
||||
for k, v := range noCacheHeaders {
|
||||
res.Header().Set(k, v)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
internal/api/middleware/noop.go
Normal file
11
internal/api/middleware/noop.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/labstack/echo/v4"
|
||||
|
||||
func Noop() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
internal/api/middleware/recover.go
Normal file
15
internal/api/middleware/recover.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func LogErrorFuncWithRequestInfo(c echo.Context, err error, stack []byte) error {
|
||||
log := util.LogFromContext(c.Request().Context())
|
||||
|
||||
log.Error().Err(err).Bytes("stack", stack).Msg("PANIC RECOVER")
|
||||
|
||||
return err
|
||||
}
|
||||
41
internal/api/middleware/recover_test.go
Normal file
41
internal/api/middleware/recover_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLogErrorFuncWithRequestInfo(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
path := "/testing-e87bc94c-2d1f-4342-9ec2-f158c63ac6da"
|
||||
|
||||
s.Echo.Use(echoMiddleware.RecoverWithConfig(echoMiddleware.RecoverConfig{
|
||||
LogErrorFunc: middleware.LogErrorFuncWithRequestInfo,
|
||||
}))
|
||||
|
||||
s.Echo.POST(path, func(c echo.Context) error {
|
||||
// trigger the recover middleware by triggering a nil pointer dereference
|
||||
var val *int
|
||||
_ = *val
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", path, nil, nil)
|
||||
require.Equal(t, http.StatusInternalServerError, res.Result().StatusCode)
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.Snapshoter.SaveString(t, string(body))
|
||||
})
|
||||
}
|
||||
81
internal/api/providers.go
Normal file
81
internal/api/providers.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/i18n"
|
||||
"allaboutapps.dev/aw/go-starter/internal/mailer"
|
||||
"allaboutapps.dev/aw/go-starter/internal/persistence"
|
||||
"allaboutapps.dev/aw/go-starter/internal/push"
|
||||
"allaboutapps.dev/aw/go-starter/internal/push/provider"
|
||||
"github.com/dropbox/godropbox/time2"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// PROVIDERS - define here only providers that for various reasons (e.g. cyclic dependency) can't live in their corresponding packages
|
||||
// or for wrapping providers that only accept sub-configs to prevent the requirements for defining providers for sub-configs.
|
||||
// https://github.com/google/wire/blob/main/docs/guide.md#defining-providers
|
||||
|
||||
// NewPush creates an instance of the push service and registers the configured push providers.
|
||||
func NewPush(cfg config.Server, db *sql.DB) (*push.Service, error) {
|
||||
pusher := push.New(db)
|
||||
|
||||
if cfg.Push.UseFCMProvider {
|
||||
fcmProvider, err := provider.NewFCM(cfg.FCMConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create FCM provider: %w", err)
|
||||
}
|
||||
pusher.RegisterProvider(fcmProvider)
|
||||
}
|
||||
|
||||
if cfg.Push.UseMockProvider {
|
||||
log.Warn().Msg("Initializing mock push provider")
|
||||
mockProvider := provider.NewMock(push.ProviderTypeFCM)
|
||||
pusher.RegisterProvider(mockProvider)
|
||||
}
|
||||
|
||||
if pusher.GetProviderCount() < 1 {
|
||||
log.Warn().Msg("No providers registered for push service")
|
||||
}
|
||||
|
||||
return pusher, nil
|
||||
}
|
||||
|
||||
func NewClock(t ...*testing.T) time2.Clock {
|
||||
var clock time2.Clock
|
||||
|
||||
useMock := len(t) > 0 && t[0] != nil
|
||||
|
||||
if useMock {
|
||||
clock = time2.NewMockClock(time.Now())
|
||||
} else {
|
||||
clock = time2.DefaultClock
|
||||
}
|
||||
|
||||
return clock
|
||||
}
|
||||
|
||||
func NewAuthService(config config.Server, db *sql.DB, clock time2.Clock) *auth.Service {
|
||||
return auth.NewService(config, db, clock)
|
||||
}
|
||||
|
||||
func NewMailer(config config.Server) (*mailer.Mailer, error) {
|
||||
return mailer.NewWithConfig(config.Mailer, config.SMTP)
|
||||
}
|
||||
|
||||
func NewDB(config config.Server) (*sql.DB, error) {
|
||||
return persistence.NewDB(config.Database)
|
||||
}
|
||||
|
||||
func NewI18N(config config.Server) (*i18n.Service, error) {
|
||||
return i18n.New(config.I18n)
|
||||
}
|
||||
|
||||
func NoTest() []*testing.T {
|
||||
return nil
|
||||
}
|
||||
13
internal/api/router/echo_logger.go
Normal file
13
internal/api/router/echo_logger.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package router
|
||||
|
||||
import "github.com/rs/zerolog"
|
||||
|
||||
type echoLogger struct {
|
||||
level zerolog.Level
|
||||
log zerolog.Logger
|
||||
}
|
||||
|
||||
func (l *echoLogger) Write(p []byte) (int, error) {
|
||||
l.log.WithLevel(l.level).Msgf("%s", p)
|
||||
return len(p), nil
|
||||
}
|
||||
27
internal/api/router/echo_renderer.go
Normal file
27
internal/api/router/echo_renderer.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/router/templates"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type echoRenderer struct {
|
||||
templates map[templates.ViewTemplate]*template.Template
|
||||
}
|
||||
|
||||
func (t *echoRenderer) Render(writer io.Writer, name string, data interface{}, _ echo.Context) error {
|
||||
tmplHTML, ok := t.templates[templates.ViewTemplate(name)]
|
||||
if !ok {
|
||||
return fmt.Errorf("template not found: %s", name)
|
||||
}
|
||||
|
||||
if err := tmplHTML.Execute(writer, data); err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
150
internal/api/router/handlers.go
Normal file
150
internal/api/router/handlers.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/timewasted/go-accept-headers"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultHTTPErrorHandlerConfig = HTTPErrorHandlerConfig{
|
||||
HideInternalServerErrorDetails: true,
|
||||
}
|
||||
)
|
||||
|
||||
type HTTPErrorHandlerConfig struct {
|
||||
HideInternalServerErrorDetails bool
|
||||
}
|
||||
|
||||
func HTTPErrorHandler() echo.HTTPErrorHandler {
|
||||
return HTTPErrorHandlerWithConfig(DefaultHTTPErrorHandlerConfig)
|
||||
}
|
||||
|
||||
func HTTPErrorHandlerWithConfig(config HTTPErrorHandlerConfig) echo.HTTPErrorHandler {
|
||||
return func(err error, c echo.Context) {
|
||||
var code int64
|
||||
var resultErr error
|
||||
|
||||
var httpError *httperrors.HTTPError
|
||||
var httpValidationError *httperrors.HTTPValidationError
|
||||
var echoHTTPError *echo.HTTPError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &httpError):
|
||||
code = *httpError.Code
|
||||
resultErr = httpError
|
||||
|
||||
if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails {
|
||||
if httpError.Internal == nil {
|
||||
//nolint:errorlint
|
||||
httpError.Internal = fmt.Errorf("internal error: %s", httpError)
|
||||
}
|
||||
|
||||
httpError.Title = swag.String(http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
case errors.As(err, &httpValidationError):
|
||||
code = *httpValidationError.Code
|
||||
resultErr = httpValidationError
|
||||
|
||||
if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails {
|
||||
if httpValidationError.Internal == nil {
|
||||
//nolint:errorlint
|
||||
httpValidationError.Internal = fmt.Errorf("internal error: %s", httpValidationError)
|
||||
}
|
||||
|
||||
httpValidationError.Title = swag.String(http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
case errors.As(err, &echoHTTPError):
|
||||
code = int64(echoHTTPError.Code)
|
||||
|
||||
//nolint:nestif
|
||||
if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails {
|
||||
if echoHTTPError.Internal == nil {
|
||||
//nolint:errorlint
|
||||
echoHTTPError.Internal = fmt.Errorf("internal error: %s", echoHTTPError)
|
||||
}
|
||||
|
||||
resultErr = &httperrors.HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(echoHTTPError.Code)),
|
||||
Title: swag.String(http.StatusText(http.StatusInternalServerError)),
|
||||
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
|
||||
},
|
||||
Internal: echoHTTPError.Internal,
|
||||
}
|
||||
} else {
|
||||
msg, ok := echoHTTPError.Message.(string)
|
||||
if !ok {
|
||||
if m, errr := json.Marshal(msg); err == nil {
|
||||
msg = string(m)
|
||||
} else {
|
||||
msg = fmt.Sprintf("failed to marshal HTTP error message: %v", errr)
|
||||
}
|
||||
}
|
||||
|
||||
resultErr = &httperrors.HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(echoHTTPError.Code)),
|
||||
Title: &msg,
|
||||
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
|
||||
},
|
||||
Internal: echoHTTPError.Internal,
|
||||
}
|
||||
}
|
||||
default:
|
||||
code = http.StatusInternalServerError
|
||||
if config.HideInternalServerErrorDetails {
|
||||
resultErr = &httperrors.HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(http.StatusInternalServerError)),
|
||||
Title: swag.String(http.StatusText(http.StatusInternalServerError)),
|
||||
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
|
||||
},
|
||||
|
||||
Internal: err,
|
||||
}
|
||||
} else {
|
||||
resultErr = &httperrors.HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(http.StatusInternalServerError)),
|
||||
Title: swag.String(err.Error()),
|
||||
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !c.Response().Committed {
|
||||
if c.Request().Method == http.MethodHead {
|
||||
err = c.NoContent(int(code))
|
||||
} else {
|
||||
err = c.JSON(int(code), resultErr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
util.LogFromEchoContext(c).Warn().Err(err).AnErr("http_err", err).Msg("Failed to handle HTTP error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NotFoundHandler(config config.Server) func(c echo.Context) error {
|
||||
return func(c echo.Context) error {
|
||||
accepted := accept.Parse(c.Request().Header.Get(echo.HeaderAccept))
|
||||
|
||||
if accepted.Accepts(echo.MIMETextHTML) {
|
||||
return c.HTML(http.StatusNotFound, fmt.Sprintf(`<html><body><h1>Page Not Found</h1><p>The page you are looking for does not exist. Did you mean to visit <a href="%s">%s</a>?</p></body></html>`, config.Frontend.BaseURL, config.Frontend.BaseURL))
|
||||
}
|
||||
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
}
|
||||
252
internal/api/router/router.go
Normal file
252
internal/api/router/router.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/constants"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/router/templates"
|
||||
"github.com/labstack/echo-contrib/echoprometheus"
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
// #nosec G108 - pprof handlers (conditionally made available via http.DefaultServeMux)
|
||||
"net/http/pprof"
|
||||
)
|
||||
|
||||
func Init(s *api.Server) error {
|
||||
s.Echo = echo.New()
|
||||
|
||||
viewsRenderer := &echoRenderer{
|
||||
templates: map[templates.ViewTemplate]*template.Template{},
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(s.Config.Echo.WebTemplatesViewsBaseDirAbs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read views templates dir: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
templateName := file.Name()
|
||||
t, err := template.New(templateName).ParseGlob(filepath.Join(s.Config.Echo.WebTemplatesViewsBaseDirAbs, templateName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse template file: %w", err)
|
||||
}
|
||||
|
||||
viewsRenderer.templates[templates.ViewTemplate(templateName)] = t
|
||||
}
|
||||
|
||||
s.Echo.Renderer = viewsRenderer
|
||||
|
||||
s.Echo.Debug = s.Config.Echo.Debug
|
||||
s.Echo.HideBanner = true
|
||||
s.Echo.Logger.SetOutput(&echoLogger{level: s.Config.Logger.RequestLevel, log: log.With().Str("component", "echo").Logger()})
|
||||
echo.NotFoundHandler = NotFoundHandler(s.Config)
|
||||
|
||||
s.Echo.HTTPErrorHandler = HTTPErrorHandlerWithConfig(HTTPErrorHandlerConfig{
|
||||
HideInternalServerErrorDetails: s.Config.Echo.HideInternalServerErrorDetails,
|
||||
})
|
||||
|
||||
// ---
|
||||
// General middleware
|
||||
if s.Config.Management.EnableMetrics {
|
||||
s.Echo.Use(echoprometheus.NewMiddleware(""))
|
||||
err := s.Metrics.RegisterMetrics(context.Background())
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to register metrics")
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Warn().Msg("Disabling metrics middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableTrailingSlashMiddleware {
|
||||
s.Echo.Pre(echoMiddleware.RemoveTrailingSlash())
|
||||
} else {
|
||||
log.Warn().Msg("Disabling trailing slash middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableRecoverMiddleware {
|
||||
s.Echo.Use(echoMiddleware.RecoverWithConfig(echoMiddleware.RecoverConfig{
|
||||
LogErrorFunc: middleware.LogErrorFuncWithRequestInfo,
|
||||
}))
|
||||
} else {
|
||||
log.Warn().Msg("Disabling recover middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableSecureMiddleware {
|
||||
s.Echo.Use(echoMiddleware.SecureWithConfig(echoMiddleware.SecureConfig{
|
||||
Skipper: echoMiddleware.DefaultSecureConfig.Skipper,
|
||||
XSSProtection: s.Config.Echo.SecureMiddleware.XSSProtection,
|
||||
ContentTypeNosniff: s.Config.Echo.SecureMiddleware.ContentTypeNosniff,
|
||||
XFrameOptions: s.Config.Echo.SecureMiddleware.XFrameOptions,
|
||||
HSTSMaxAge: s.Config.Echo.SecureMiddleware.HSTSMaxAge,
|
||||
HSTSExcludeSubdomains: s.Config.Echo.SecureMiddleware.HSTSExcludeSubdomains,
|
||||
ContentSecurityPolicy: s.Config.Echo.SecureMiddleware.ContentSecurityPolicy,
|
||||
CSPReportOnly: s.Config.Echo.SecureMiddleware.CSPReportOnly,
|
||||
HSTSPreloadEnabled: s.Config.Echo.SecureMiddleware.HSTSPreloadEnabled,
|
||||
ReferrerPolicy: s.Config.Echo.SecureMiddleware.ReferrerPolicy,
|
||||
}))
|
||||
} else {
|
||||
log.Warn().Msg("Disabling secure middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableRequestIDMiddleware {
|
||||
s.Echo.Use(echoMiddleware.RequestID())
|
||||
} else {
|
||||
log.Warn().Msg("Disabling request ID middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableLoggerMiddleware {
|
||||
s.Echo.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||
Level: s.Config.Logger.RequestLevel,
|
||||
LogRequestBody: s.Config.Logger.LogRequestBody,
|
||||
LogRequestHeader: s.Config.Logger.LogRequestHeader,
|
||||
LogRequestQuery: s.Config.Logger.LogRequestQuery,
|
||||
LogResponseBody: s.Config.Logger.LogResponseBody,
|
||||
LogResponseHeader: s.Config.Logger.LogResponseHeader,
|
||||
LogCaller: s.Config.Logger.LogCaller,
|
||||
RequestBodyLogSkipper: func(req *http.Request) bool {
|
||||
// We skip all body logging for auth endpoints as these might contain credentials
|
||||
if strings.HasPrefix(req.URL.Path, "/api/v1/auth") {
|
||||
return true
|
||||
}
|
||||
|
||||
return middleware.DefaultRequestBodyLogSkipper(req)
|
||||
},
|
||||
ResponseBodyLogSkipper: func(req *http.Request, res *echo.Response) bool {
|
||||
// We skip all body logging for auth endpoints as these might contain credentials
|
||||
if strings.HasPrefix(req.URL.Path, "/api/v1/auth") {
|
||||
return true
|
||||
}
|
||||
|
||||
return middleware.DefaultResponseBodyLogSkipper(req, res)
|
||||
},
|
||||
Skipper: func(c echo.Context) bool {
|
||||
// We skip logging of readiness and liveness endpoints
|
||||
switch c.Path() {
|
||||
case "/-/ready", "/-/healthy":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
log.Warn().Msg("Disabling logger middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableCORSMiddleware {
|
||||
s.Echo.Use(echoMiddleware.CORS())
|
||||
} else {
|
||||
log.Warn().Msg("Disabling CORS middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableCacheControlMiddleware {
|
||||
s.Echo.Use(middleware.CacheControl())
|
||||
} else {
|
||||
log.Warn().Msg("Disabling cache control middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Pprof.Enable {
|
||||
pprofAuthMiddleware := middleware.Noop()
|
||||
|
||||
if s.Config.Pprof.EnableManagementKeyAuth {
|
||||
pprofAuthMiddleware = echoMiddleware.KeyAuthWithConfig(echoMiddleware.KeyAuthConfig{
|
||||
KeyLookup: "query:mgmt-secret",
|
||||
Validator: func(key string, _ echo.Context) (bool, error) {
|
||||
return key == s.Config.Management.Secret, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
s.Echo.GET("/debug/pprof", echo.WrapHandler(http.HandlerFunc(pprof.Index)), pprofAuthMiddleware)
|
||||
s.Echo.Any("/debug/pprof/*", echo.WrapHandler(http.DefaultServeMux), pprofAuthMiddleware)
|
||||
|
||||
log.Warn().Bool("EnableManagementKeyAuth", s.Config.Pprof.EnableManagementKeyAuth).Msg("Pprof http handlers are available at /debug/pprof")
|
||||
|
||||
if s.Config.Pprof.RuntimeBlockProfileRate != 0 {
|
||||
runtime.SetBlockProfileRate(s.Config.Pprof.RuntimeBlockProfileRate)
|
||||
log.Warn().Int("RuntimeBlockProfileRate", s.Config.Pprof.RuntimeBlockProfileRate).Msg("Pprof runtime.SetBlockProfileRate")
|
||||
}
|
||||
|
||||
if s.Config.Pprof.RuntimeMutexProfileFraction != 0 {
|
||||
runtime.SetMutexProfileFraction(s.Config.Pprof.RuntimeMutexProfileFraction)
|
||||
log.Warn().Int("RuntimeMutexProfileFraction", s.Config.Pprof.RuntimeMutexProfileFraction).Msg("Pprof runtime.SetMutexProfileFraction")
|
||||
}
|
||||
}
|
||||
|
||||
// Add your custom / additional middlewares here.
|
||||
// see https://echo.labstack.com/middleware
|
||||
|
||||
// ---
|
||||
// Initialize our general groups and set middleware to use above them
|
||||
s.Router = &api.Router{
|
||||
Routes: nil, // will be populated by handlers.AttachAllRoutes(s)
|
||||
|
||||
// Unsecured base group available at /**
|
||||
Root: s.Echo.Group(""),
|
||||
|
||||
// Management endpoints, uncacheable, secured by key auth (query param), available at /-/**
|
||||
Management: s.Echo.Group("/-", echoMiddleware.KeyAuthWithConfig(echoMiddleware.KeyAuthConfig{
|
||||
KeyLookup: "query:mgmt-secret",
|
||||
Validator: func(key string, _ echo.Context) (bool, error) {
|
||||
return key == s.Config.Management.Secret, nil
|
||||
},
|
||||
Skipper: func(c echo.Context) bool {
|
||||
//nolint:gocritic
|
||||
switch c.Path() {
|
||||
case "/-/ready":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}), middleware.NoCache()),
|
||||
|
||||
// OAuth2, unsecured or secured by bearer auth, available at /api/v1/auth/**
|
||||
APIV1Auth: s.Echo.Group("/api/v1/auth", middleware.AuthWithConfig(middleware.AuthConfig{
|
||||
S: s,
|
||||
Mode: middleware.AuthModeRequired,
|
||||
Skipper: func(c echo.Context) bool {
|
||||
switch c.Path() {
|
||||
case "/api/v1/auth/forgot-password",
|
||||
"/api/v1/auth/forgot-password/complete",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/refresh",
|
||||
"/api/v1/auth/register",
|
||||
fmt.Sprintf("/api/v1/auth/register/:%s", constants.RegistrationTokenParam):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
})),
|
||||
WellKnown: s.Echo.Group("/.well-known"),
|
||||
|
||||
// Your other endpoints, typically secured by bearer auth, available at /api/v1/**
|
||||
APIV1Push: s.Echo.Group("/api/v1/push", middleware.Auth(s)),
|
||||
}
|
||||
|
||||
// ---
|
||||
// Finally attach our handlers
|
||||
handlers.AttachAllRoutes(s)
|
||||
|
||||
if s.Config.Management.EnableMetrics {
|
||||
log.Info().Msg("Metrics enabled and available under /metrics")
|
||||
s.Echo.GET("/metrics", echoprometheus.NewHandler())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
128
internal/api/router/router_test.go
Normal file
128
internal/api/router/router_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/metrics/users"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPprofEnabled(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
|
||||
// these are typically our default values, however we force set them here to ensure those are set while test execution.
|
||||
config.Pprof.Enable = true
|
||||
config.Pprof.EnableManagementKeyAuth = true
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
// heap (test any)
|
||||
res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 200, res.Result().StatusCode)
|
||||
|
||||
// index
|
||||
res = test.PerformRequest(t, s, "GET", "/debug/pprof?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 200, res.Result().StatusCode)
|
||||
|
||||
res = test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret=wrongsecret", nil, nil)
|
||||
require.Equal(t, 401, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPprofEnabledNoAuth(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
|
||||
// these are typically our default values, however we force set them here to ensure those are set while test execution.
|
||||
config.Pprof.Enable = true
|
||||
config.Pprof.EnableManagementKeyAuth = false
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?", nil, nil)
|
||||
require.Equal(t, 200, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPprofDisabled(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Pprof.Enable = false
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 404, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMiddlewaresDisabled(t *testing.T) {
|
||||
// disable all
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Echo.EnableCORSMiddleware = false
|
||||
config.Echo.EnableLoggerMiddleware = false
|
||||
config.Echo.EnableRecoverMiddleware = false
|
||||
config.Echo.EnableRequestIDMiddleware = false
|
||||
config.Echo.EnableSecureMiddleware = false
|
||||
config.Echo.EnableTrailingSlashMiddleware = false
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
|
||||
require.Equal(t, 200, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMetricsEnabled(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Management.EnableMetrics = true
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/metrics", nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
result := res.Body.String()
|
||||
|
||||
// expect custom metric for the total user count
|
||||
expectedTotalUserCount, err := models.Users().Count(t.Context(), s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, fmt.Sprintf("%s %d", users.MetricNameTotalUsers, expectedTotalUserCount))
|
||||
|
||||
// expect sqlstats metrics
|
||||
assert.Contains(t, result, "go_sql_stats_connections")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMetricsDisabled(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/metrics", nil, nil)
|
||||
require.Equal(t, http.StatusNotFound, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
t.Run("AcceptApplicationJSON", func(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set(echo.HeaderAccept, echo.MIMEApplicationJSON)
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/api/v1/unknown-path", nil, headers)
|
||||
require.Equal(t, http.StatusNotFound, res.Result().StatusCode)
|
||||
|
||||
test.Snapshoter.Save(t, res.Body.String())
|
||||
})
|
||||
|
||||
t.Run("AcceptTextHTML", func(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set(echo.HeaderAccept, "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/api/v1/unknown-path", nil, headers)
|
||||
require.Equal(t, http.StatusNotFound, res.Result().StatusCode)
|
||||
|
||||
test.Snapshoter.Save(t, res.Body.String())
|
||||
})
|
||||
})
|
||||
}
|
||||
11
internal/api/router/templates/templates.go
Normal file
11
internal/api/router/templates/templates.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package templates
|
||||
|
||||
type ViewTemplate string
|
||||
|
||||
const (
|
||||
ViewTemplateAccountConfirmation ViewTemplate = "account_confirmation.html.tmpl"
|
||||
)
|
||||
|
||||
func (vt ViewTemplate) String() string {
|
||||
return string(vt)
|
||||
}
|
||||
154
internal/api/server.go
Normal file
154
internal/api/server.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/local"
|
||||
"allaboutapps.dev/aw/go-starter/internal/i18n"
|
||||
"allaboutapps.dev/aw/go-starter/internal/mailer"
|
||||
"allaboutapps.dev/aw/go-starter/internal/metrics"
|
||||
"allaboutapps.dev/aw/go-starter/internal/push"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/dropbox/godropbox/time2"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
// Import postgres driver for database/sql package
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
Routes []*echo.Route
|
||||
Root *echo.Group
|
||||
Management *echo.Group
|
||||
APIV1Auth *echo.Group
|
||||
APIV1Push *echo.Group
|
||||
WellKnown *echo.Group
|
||||
}
|
||||
|
||||
// Server is a central struct keeping all the dependencies.
|
||||
// It is initialized with wire, which handles making the new instances of the components
|
||||
// in the right order. To add a new component, 3 steps are required:
|
||||
// - declaring it in this struct
|
||||
// - adding a provider function in providers.go
|
||||
// - adding the provider's function name to the arguments of wire.Build() in wire.go
|
||||
//
|
||||
// Components labeled as `wire:"-"` will be skipped and have to be initialized after the InitNewServer* call.
|
||||
// For more information about wire refer to https://pkg.go.dev/github.com/google/wire
|
||||
type Server struct {
|
||||
// skip wire:
|
||||
// -> initialized with router.Init(s) function
|
||||
Echo *echo.Echo `wire:"-"`
|
||||
Router *Router `wire:"-"`
|
||||
|
||||
Config config.Server
|
||||
DB *sql.DB
|
||||
Mailer *mailer.Mailer
|
||||
Push *push.Service
|
||||
I18n *i18n.Service
|
||||
Clock time2.Clock
|
||||
Auth AuthService
|
||||
Local *local.Service
|
||||
Metrics *metrics.Service
|
||||
}
|
||||
|
||||
// newServerWithComponents is used by wire to initialize the server components.
|
||||
// Components not listed here won't be handled by wire and should be initialized separately.
|
||||
// Components which shouldn't be handled must be labeled `wire:"-"` in Server struct.
|
||||
func newServerWithComponents(
|
||||
cfg config.Server,
|
||||
db *sql.DB,
|
||||
mail *mailer.Mailer,
|
||||
pusher *push.Service,
|
||||
i18n *i18n.Service,
|
||||
clock time2.Clock,
|
||||
auth AuthService,
|
||||
local *local.Service,
|
||||
metrics *metrics.Service,
|
||||
) *Server {
|
||||
return &Server{
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
Mailer: mail,
|
||||
Push: pusher,
|
||||
I18n: i18n,
|
||||
Clock: clock,
|
||||
Auth: auth,
|
||||
Local: local,
|
||||
Metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
type AuthService interface {
|
||||
GetAppUserProfile(ctx context.Context, id string) (*dto.AppUserProfile, error)
|
||||
InitPasswordReset(ctx context.Context, request dto.InitPasswordResetRequest) (dto.InitPasswordResetResult, error)
|
||||
Login(ctx context.Context, request dto.LoginRequest) (dto.LoginResult, error)
|
||||
Logout(ctx context.Context, request dto.LogoutRequest) error
|
||||
Refresh(ctx context.Context, request dto.RefreshRequest) (dto.LoginResult, error)
|
||||
Register(ctx context.Context, request dto.RegisterRequest) (dto.RegisterResult, error)
|
||||
CompleteRegister(ctx context.Context, request dto.CompleteRegisterRequest) (dto.LoginResult, error)
|
||||
DeleteUserAccount(ctx context.Context, request dto.DeleteUserAccountRequest) error
|
||||
ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (dto.LoginResult, error)
|
||||
UpdatePassword(ctx context.Context, request dto.UpdatePasswordRequest) (dto.LoginResult, error)
|
||||
}
|
||||
|
||||
func NewServer(config config.Server) *Server {
|
||||
s := &Server{
|
||||
Config: config,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Ready() bool {
|
||||
if err := util.IsStructInitialized(s); err != nil {
|
||||
log.Debug().Err(err).Msg("Server is not fully initialized")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
if !s.Ready() {
|
||||
return errors.New("server is not ready")
|
||||
}
|
||||
|
||||
if err := s.Echo.Start(s.Config.Echo.ListenAddress); err != nil {
|
||||
return fmt.Errorf("failed to start echo server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) []error {
|
||||
log.Warn().Msg("Shutting down server")
|
||||
|
||||
var errs []error
|
||||
|
||||
if s.DB != nil {
|
||||
log.Debug().Msg("Closing database connection")
|
||||
|
||||
if err := s.DB.Close(); err != nil && !errors.Is(err, sql.ErrConnDone) {
|
||||
log.Error().Err(err).Msg("Failed to close database connection")
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if s.Echo != nil {
|
||||
log.Debug().Msg("Shutting down echo server")
|
||||
|
||||
if err := s.Echo.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error().Err(err).Msg("Failed to shutdown echo server")
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
52
internal/api/wire.go
Normal file
52
internal/api/wire.go
Normal file
@@ -0,0 +1,52 @@
|
||||
//go:build wireinject
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/local"
|
||||
"allaboutapps.dev/aw/go-starter/internal/metrics"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
// INJECTORS - https://github.com/google/wire/blob/main/docs/guide.md#injectors
|
||||
|
||||
// serviceSet groups the default set of providers that are required for initing a server
|
||||
var serviceSet = wire.NewSet(
|
||||
newServerWithComponents,
|
||||
NewPush,
|
||||
NewMailer,
|
||||
NewI18N,
|
||||
authServiceSet,
|
||||
local.NewService,
|
||||
metrics.New,
|
||||
NewClock,
|
||||
)
|
||||
|
||||
var authServiceSet = wire.NewSet(
|
||||
NewAuthService,
|
||||
wire.Bind(new(AuthService), new(*auth.Service)),
|
||||
)
|
||||
|
||||
// InitNewServer returns a new Server instance.
|
||||
func InitNewServer(
|
||||
_ config.Server,
|
||||
) (*Server, error) {
|
||||
wire.Build(serviceSet, NewDB, NoTest)
|
||||
return new(Server), nil
|
||||
}
|
||||
|
||||
// InitNewServerWithDB returns a new Server instance with the given DB instance.
|
||||
// All the other components are initialized via go wire according to the configuration.
|
||||
func InitNewServerWithDB(
|
||||
_ config.Server,
|
||||
_ *sql.DB,
|
||||
t ...*testing.T,
|
||||
) (*Server, error) {
|
||||
wire.Build(serviceSet)
|
||||
return new(Server), nil
|
||||
}
|
||||
94
internal/api/wire_gen.go
Normal file
94
internal/api/wire_gen.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/local"
|
||||
"allaboutapps.dev/aw/go-starter/internal/metrics"
|
||||
"database/sql"
|
||||
"github.com/google/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
import (
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Injectors from wire.go:
|
||||
|
||||
// InitNewServer returns a new Server instance.
|
||||
func InitNewServer(server config.Server) (*Server, error) {
|
||||
db, err := NewDB(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailer, err := NewMailer(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, err := NewPush(server, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i18nService, err := NewI18N(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := NoTest()
|
||||
clock := NewClock(v...)
|
||||
authService := NewAuthService(server, db, clock)
|
||||
localService := local.NewService(server, db, clock)
|
||||
metricsService, err := metrics.New(server, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiServer := newServerWithComponents(server, db, mailer, service, i18nService, clock, authService, localService, metricsService)
|
||||
return apiServer, nil
|
||||
}
|
||||
|
||||
// InitNewServerWithDB returns a new Server instance with the given DB instance.
|
||||
// All the other components are initialized via go wire according to the configuration.
|
||||
func InitNewServerWithDB(server config.Server, db *sql.DB, t ...*testing.T) (*Server, error) {
|
||||
mailer, err := NewMailer(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, err := NewPush(server, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i18nService, err := NewI18N(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clock := NewClock(t...)
|
||||
authService := NewAuthService(server, db, clock)
|
||||
localService := local.NewService(server, db, clock)
|
||||
metricsService, err := metrics.New(server, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiServer := newServerWithComponents(server, db, mailer, service, i18nService, clock, authService, localService, metricsService)
|
||||
return apiServer, nil
|
||||
}
|
||||
|
||||
// wire.go:
|
||||
|
||||
// serviceSet groups the default set of providers that are required for initing a server
|
||||
var serviceSet = wire.NewSet(
|
||||
newServerWithComponents,
|
||||
NewPush,
|
||||
NewMailer,
|
||||
NewI18N,
|
||||
authServiceSet, local.NewService, metrics.New, NewClock,
|
||||
)
|
||||
|
||||
var authServiceSet = wire.NewSet(
|
||||
NewAuthService, wire.Bind(new(AuthService), new(*auth.Service)),
|
||||
)
|
||||
Reference in New Issue
Block a user