(Feat): Initial Commit
Some checks failed
Build & Test / build-test (push) Has been cancelled
Build & Test / swagger-codegen-cli (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled

This commit is contained in:
2026-07-03 19:41:31 +05:30
commit 7e940c83a7
461 changed files with 45002 additions and 0 deletions

View 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)
}
}

View 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)
})
}

View 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, &params); 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(),
})
}
}

View 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))
})
}

View 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())
}
}

View 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)
})
}

View 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())
}
}

View 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)
})
}
})
}

View 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, &params); 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())
}
}

View 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))
})
}

View 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)
}
}

View 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())
}
}

View 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)
})
}
})
}

View 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)
})
}
})
}

View 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())
}
}

View 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)
})
}
})
}

View 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)
}
}

View 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)
})
}
})
}

View 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())
}
}

View 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)
})
}
})
}

View 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),
})
}
}

View 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, &registerAgainResponse)
// 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, &registerLaterAgainResponse)
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)
})
}
})
}

View 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())
}
}

View 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)
})
}

View 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.")
}
}

View 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())
})
}

View 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())
}

View 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"))
})
}

View 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())
}
}

View 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"))
})
}

View 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
}

View 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)
}

View File

@@ -0,0 +1,5 @@
package constants
const (
RegistrationTokenParam = "registrationToken"
)

View 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),
}
}

View 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")
}
}

View 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)
})
}

View 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)
}
}

View File

@@ -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))
})
}

View File

@@ -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)
}
}

View File

@@ -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))
})
}