(Feat): Initial Commit
This commit is contained in:
55
internal/README.md
Normal file
55
internal/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# `/internal`
|
||||
|
||||
Private application and library code. This is the code you don't want others importing in their applications or libraries. Note that this layout pattern is enforced by the Go compiler itself. See the Go 1.4 [`release notes`](https://golang.org/doc/go1.4#internalpackages) for more details. Note that you are not limited to the top level `internal` directory. You can have more than one `internal` directory at any level of your project tree.
|
||||
|
||||
You can optionally add a bit of extra structure to your internal packages to separate your shared and non-shared internal code. It's not required (especially for smaller projects), but it's nice to have visual clues showing the intended package use. Your actual application code can go in the `/internal/app` directory (e.g., `/internal/app/myapp`) and the code shared by those apps in the `/internal/pkg` directory (e.g., `/internal/pkg/myprivlib`).
|
||||
|
||||
https://github.com/golang-standards/project-layout/tree/master/internal
|
||||
|
||||
### `/internal/api`
|
||||
|
||||
Holds API implementations (`/internal/api/handlers`) and general server, router and middleware setup.
|
||||
|
||||
### `/internal/config`
|
||||
|
||||
Holds configuration of this project (translation of `ENV` vars into something useable).
|
||||
|
||||
### `/internal/data`
|
||||
|
||||
Anything data related (e.g. mappers, DAOs, DTOs, ...), may be especially relevant if you do 3rd party data integrations. Use this package to write **reuseable** functions for your `/internal/api/handlers/*`.
|
||||
|
||||
May hold live db fixture data (for `app db seed`).
|
||||
|
||||
### `/internal/i18n`
|
||||
|
||||
Our implementation for i18n/l10n, as available via `api.Server.I18n`. Your own localized i18n translation bundles should live within **`/web/i18n`**.
|
||||
|
||||
### `/internal/mailer`
|
||||
|
||||
Email handling sub-service.
|
||||
|
||||
### `/internal/models`
|
||||
|
||||
> **Autogenerated** [SQLBoiler](https://github.com/volatiletech/sqlboiler#getting-started) models. Do *not* put your own files in here.
|
||||
|
||||
These are based on your current database in `../migrations/*.sql` and updated while running `make`.
|
||||
|
||||
### `/internal/push`
|
||||
|
||||
Push notifications sub-service.
|
||||
|
||||
### `/internal/test`
|
||||
|
||||
Test-Setup related code and general testing utility functions.
|
||||
|
||||
Holds test db fixture data (`/internal/test/fixtures.go`).
|
||||
|
||||
### `/internal/types`
|
||||
|
||||
> **Autogenerated** [go-swagger](https://github.com/go-swagger/go-swagger) types and validations. Do *not* put your own files in here.
|
||||
|
||||
These are based on your Swagger OpenAPI specification in `../api/**/*.yml` and updated while running `make`.
|
||||
|
||||
### `/internal/util`
|
||||
|
||||
Utility functions.
|
||||
41
internal/api/handlers/auth/delete_user_account.go
Normal file
41
internal/api/handlers/auth/delete_user_account.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func DeleteUserAccountRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.DELETE("/account", deleteUserAccountHandler(s))
|
||||
}
|
||||
|
||||
func deleteUserAccountHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
user := auth.UserFromContext(ctx)
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.DeleteUserAccountPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := s.Auth.DeleteUserAccount(ctx, dto.DeleteUserAccountRequest{
|
||||
User: *user,
|
||||
CurrentPassword: swag.StringValue(body.CurrentPassword),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to delete user")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
136
internal/api/handlers/auth/delete_user_account_test.go
Normal file
136
internal/api/handlers/auth/delete_user_account_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func assertUserAndRelatedData(ctx context.Context, t *testing.T, s *api.Server, userID string, expectExists bool) {
|
||||
t.Helper()
|
||||
|
||||
userExists, err := models.Users(
|
||||
models.UserWhere.ID.EQ(userID),
|
||||
).Exists(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectExists, userExists)
|
||||
|
||||
appUserProfileExists, err := models.AppUserProfiles(
|
||||
models.AppUserProfileWhere.UserID.EQ(userID),
|
||||
).Exists(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectExists, appUserProfileExists)
|
||||
|
||||
accessTokenExists, err := models.AccessTokens(
|
||||
models.AccessTokenWhere.UserID.EQ(userID),
|
||||
).Exists(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectExists, accessTokenExists)
|
||||
|
||||
refreshTokenExists, err := models.RefreshTokens(
|
||||
models.RefreshTokenWhere.UserID.EQ(userID),
|
||||
).Exists(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectExists, refreshTokenExists)
|
||||
|
||||
pushTokenExists, err := models.PushTokens(
|
||||
models.PushTokenWhere.UserID.EQ(userID),
|
||||
).Exists(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectExists, pushTokenExists)
|
||||
}
|
||||
|
||||
func TestDeleteUserAccount(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
// expect the user to have a app user profile and different kinds of tokens (access, refresh, push, password reset)
|
||||
assertUserAndRelatedData(ctx, t, s, fix.User1.ID, true)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
// expect the user and all related data to be deleted
|
||||
assertUserAndRelatedData(ctx, t, s, fix.User1.ID, false)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserAccountCurrentPasswordWrong(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": "wrongpassword",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserAccountMissingCurrentPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserAccountNoAuth(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserAccountUserNotActive(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
ctx := t.Context()
|
||||
|
||||
fix.User1.IsActive = false
|
||||
_, err := fix.User1.Update(ctx, s.DB, boil.Whitelist(models.UserColumns.IsActive))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": "somepassword",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteUserAccountUserNotLocal(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
ctx := t.Context()
|
||||
|
||||
fix.User1.Password = null.String{}
|
||||
_, err := fix.User1.Update(ctx, s.DB, boil.Whitelist(models.UserColumns.Password))
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": "wrongpassword",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "DELETE", "/api/v1/auth/account", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser)
|
||||
})
|
||||
}
|
||||
39
internal/api/handlers/auth/get_complete_register.go
Normal file
39
internal/api/handlers/auth/get_complete_register.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/router/templates"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/url"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetCompleteRegisterRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.GET("/register", getCompleteRegisterHandler(s))
|
||||
}
|
||||
|
||||
func getCompleteRegisterHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
params := auth.NewGetCompleteRegisterRouteParams()
|
||||
if err := util.BindAndValidatePathAndQueryParams(c, ¶ms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
confirmationRequestURL, err := url.ConfirmationRequestURL(s.Config, params.Token.String())
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to generate confirmation link")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, templates.ViewTemplateAccountConfirmation.String(), map[string]interface{}{
|
||||
"confirmationRequestURL": confirmationRequestURL.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
27
internal/api/handlers/auth/get_complete_register_test.go
Normal file
27
internal/api/handlers/auth/get_complete_register_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetCompleteRegister(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", fmt.Sprintf("/api/v1/auth/register?token=%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
response, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.Snapshoter.SaveString(t, string(response))
|
||||
})
|
||||
}
|
||||
33
internal/api/handlers/auth/get_userinfo.go
Normal file
33
internal/api/handlers/auth/get_userinfo.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetUserInfoRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.GET("/userinfo", getUserInfoHandler(s))
|
||||
}
|
||||
|
||||
func getUserInfoHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
user := auth.UserFromContext(ctx)
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var err error
|
||||
|
||||
user.Profile, err = s.Auth.GetAppUserProfile(ctx, user.ID)
|
||||
if err != nil && !errors.Is(err, auth.ErrNotFound) {
|
||||
log.Debug().Err(err).Msg("Failed to get user profile")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, user.ToTypes())
|
||||
}
|
||||
}
|
||||
69
internal/api/handlers/auth/get_userinfo_test.go
Normal file
69
internal/api/handlers/auth/get_userinfo_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetUserInfo(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.GetUserInfoResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.Equal(t, fix.User1.ID, *response.Sub)
|
||||
assert.Equal(t, strfmt.Email(fix.User1.Username.String), response.Email)
|
||||
test.Snapshoter.Skip([]string{"UpdatedAt"}).Save(t, response)
|
||||
|
||||
for _, scope := range fix.User1.Scopes {
|
||||
assert.Contains(t, response.Scopes, scope)
|
||||
}
|
||||
|
||||
appUserProfile, err := models.FindAppUserProfile(ctx, s.DB, fix.User1.ID, models.AccessTokenColumns.UpdatedAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, appUserProfile.UpdatedAt.Unix(), *response.UpdatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetUserInfoMinimal(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
_, err := models.AppUserProfiles(models.AppUserProfileWhere.UserID.EQ(fix.User1.ID)).DeleteAll(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.GetUserInfoResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.Equal(t, fix.User1.ID, *response.Sub)
|
||||
assert.Equal(t, strfmt.Email(fix.User1.Username.String), response.Email)
|
||||
|
||||
for _, scope := range fix.User1.Scopes {
|
||||
assert.Contains(t, response.Scopes, scope)
|
||||
}
|
||||
|
||||
user, err := models.FindUser(ctx, s.DB, fix.User1.ID, models.UserColumns.UpdatedAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, user.UpdatedAt.Unix(), *response.UpdatedAt)
|
||||
})
|
||||
}
|
||||
42
internal/api/handlers/auth/post_change_password.go
Normal file
42
internal/api/handlers/auth/post_change_password.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostChangePasswordRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/change-password", postChangePasswordHandler(s))
|
||||
}
|
||||
|
||||
func postChangePasswordHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
user := auth.UserFromEchoContext(c)
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostChangePasswordPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.Auth.UpdatePassword(ctx, dto.UpdatePasswordRequest{
|
||||
User: *user,
|
||||
CurrentPassword: swag.StringValue(body.CurrentPassword),
|
||||
NewPassword: swag.StringValue(body.NewPassword),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to update password")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
|
||||
}
|
||||
}
|
||||
187
internal/api/handlers/auth/post_change_password_test.go
Normal file
187
internal/api/handlers/auth/post_change_password_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
newPassword = "correct horse battery staple"
|
||||
)
|
||||
|
||||
func TestPostChangePasswordSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
"newPassword": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEqual(t, fix.User1AccessToken1.Token, *response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.NotEqual(t, fix.User1RefreshToken1.Token, *response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.User1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostChangePasswordInvalidPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": "not my password",
|
||||
"newPassword": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostChangePasswordDeactivatedUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
"newPassword": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.UserDeactivatedAccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostChangePasswordUserWithoutPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
"newPassword": newPassword,
|
||||
}
|
||||
|
||||
fix.User2.Password = null.String{}
|
||||
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAff)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", payload, test.HeadersWithAuth(t, fix.User2AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser)
|
||||
|
||||
err = fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostChangePasswordBadRequest(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "MissingCurrentPassword",
|
||||
payload: test.GenericPayload{
|
||||
"newPassword": newPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MissingNewPassword",
|
||||
payload: test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyCurrentPassword",
|
||||
payload: test.GenericPayload{
|
||||
"currentPassword": "",
|
||||
"newPassword": newPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyNewPassword",
|
||||
payload: test.GenericPayload{
|
||||
"currentPassword": fixtures.PlainTestUserPassword,
|
||||
"newPassword": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/change-password", tt.payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
40
internal/api/handlers/auth/post_complete_register.go
Normal file
40
internal/api/handlers/auth/post_complete_register.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/constants"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types/auth"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostCompleteRegisterRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST(fmt.Sprintf("/register/:%s", constants.RegistrationTokenParam), postCompleteRegisterHandler(s))
|
||||
}
|
||||
|
||||
func postCompleteRegisterHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
params := auth.NewPostCompleteRegisterRouteParams()
|
||||
if err := util.BindAndValidatePathAndQueryParams(c, ¶ms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.Auth.CompleteRegister(ctx, dto.CompleteRegisterRequest{
|
||||
ConfirmationToken: params.RegistrationToken.String(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to complete registration")
|
||||
return echo.ErrUnauthorized
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
|
||||
}
|
||||
}
|
||||
70
internal/api/handlers/auth/post_complete_register_test.go
Normal file
70
internal/api/handlers/auth/post_complete_register_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostCompleteRegister(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
ctx := t.Context()
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.ID.EQ(fix.UserRequiresConfirmationConfirmationToken.UserID),
|
||||
).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, user.IsActive)
|
||||
assert.False(t, user.RequiresConfirmation)
|
||||
|
||||
// trying again with the same token should fail
|
||||
{
|
||||
res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostCompleteRegisterTokenNotFound(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register/e45071b7-b9a0-4ed7-a5a0-16a16413d275", nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostCompleteRegisterTokenExpired(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
ctx := t.Context()
|
||||
|
||||
fix.UserRequiresConfirmationConfirmationToken.ValidUntil = s.Clock.Now().Add(-1 * time.Second)
|
||||
_, err := fix.UserRequiresConfirmationConfirmationToken.Update(ctx, s.DB, boil.Whitelist(models.ConfirmationTokenColumns.ValidUntil))
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", fmt.Sprintf("/api/v1/auth/register/%s", fix.UserRequiresConfirmationConfirmationToken.Token), nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
57
internal/api/handlers/auth/post_forgot_password.go
Normal file
57
internal/api/handlers/auth/post_forgot_password.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/url"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func PostForgotPasswordRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/forgot-password", postForgotPasswordHandler(s))
|
||||
}
|
||||
|
||||
func postForgotPasswordHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
var body types.PostForgotPasswordPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := dto.NewUsername(body.Username.String())
|
||||
|
||||
result, err := s.Auth.InitPasswordReset(ctx, dto.InitPasswordResetRequest{
|
||||
Username: username,
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to initiate password reset")
|
||||
return err
|
||||
}
|
||||
|
||||
if result.ResetToken.IsZero() {
|
||||
log.Debug().Msg("Failed to initiate password reset, no token returned")
|
||||
// Return success status to prevent user enumeration
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
resetLink, err := url.PasswordResetDeeplinkURL(s.Config, result.ResetToken.String)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to generate password reset link")
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.Mailer.SendPasswordReset(ctx, username.String(), resetLink.String()); err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to send password reset email")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
39
internal/api/handlers/auth/post_forgot_password_complete.go
Normal file
39
internal/api/handlers/auth/post_forgot_password_complete.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostForgotPasswordCompleteRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/forgot-password/complete", postForgotPasswordCompleteHandler(s))
|
||||
}
|
||||
|
||||
func postForgotPasswordCompleteHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostForgotPasswordCompletePayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.Auth.ResetPassword(ctx, dto.ResetPasswordRequest{
|
||||
ResetToken: body.Token.String(),
|
||||
NewPassword: swag.StringValue(body.Password),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to reset password")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
|
||||
}
|
||||
}
|
||||
280
internal/api/handlers/auth/post_forgot_password_complete_test.go
Normal file
280
internal/api/handlers/auth/post_forgot_password_complete_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostForgotPasswordCompleteSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
passwordResetToken := models.PasswordResetToken{
|
||||
UserID: fix.User1.ID,
|
||||
ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity),
|
||||
}
|
||||
|
||||
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"token": passwordResetToken.Token,
|
||||
"password": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.NotEqual(t, fix.User1RefreshToken1.Token, *response.RefreshToken)
|
||||
test.Snapshoter.Skip([]string{"AccessToken", "RefreshToken"}).Save(t, response)
|
||||
|
||||
err = fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.User1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordCompleteUnknownToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"token": "fd5c04ea-f39c-49e9-bb40-7f570ed1f66f",
|
||||
"password": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrNotFoundTokenNotFound)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.User1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordCompleteExpiredToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
passwordResetToken := models.PasswordResetToken{
|
||||
UserID: fix.User1.ID,
|
||||
ValidUntil: s.Clock.Now().Add(time.Minute * -10),
|
||||
}
|
||||
|
||||
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"token": passwordResetToken.Token,
|
||||
"password": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrConflictTokenExpired)
|
||||
|
||||
err = fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.User1.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.User1.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.User1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fixtures.HashedTestUserPassword, fix.User1.Password.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordCompleteDeactivatedUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
passwordResetToken := models.PasswordResetToken{
|
||||
UserID: fix.UserDeactivated.ID,
|
||||
ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity),
|
||||
}
|
||||
|
||||
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"token": passwordResetToken.Token,
|
||||
"password": newPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
|
||||
|
||||
err = fix.UserDeactivatedAccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.UserDeactivatedRefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.UserDeactivated.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.UserDeactivated.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.UserDeactivated.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fixtures.HashedTestUserPassword, fix.UserDeactivated.Password.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordCompleteUserWithoutPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
passwordResetToken := models.PasswordResetToken{
|
||||
UserID: fix.User2.ID,
|
||||
ValidUntil: s.Clock.Now().Add(s.Config.Auth.PasswordResetTokenValidity),
|
||||
}
|
||||
|
||||
err := passwordResetToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"token": passwordResetToken.Token,
|
||||
"password": newPassword,
|
||||
}
|
||||
|
||||
fix.User2.Password = null.String{}
|
||||
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAff)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenNotLocalUser)
|
||||
|
||||
err = fix.User2AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
err = fix.User2RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.User2.AccessTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
cnt, err = fix.User2.RefreshTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), cnt)
|
||||
|
||||
err = fix.User2.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, fix.User2.Password.Valid)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordCompleteBadRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "MissingToken",
|
||||
payload: test.GenericPayload{
|
||||
"password": "correct horse battery stable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MissingPassword",
|
||||
payload: test.GenericPayload{
|
||||
"token": "7b6e2366-7806-421f-bd56-ffcb39d7b1ee",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidToken",
|
||||
payload: test.GenericPayload{
|
||||
"token": "definitelydoesnotexist",
|
||||
"password": "correct horse battery stable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyToken",
|
||||
payload: test.GenericPayload{
|
||||
"password": "correct horse battery stable",
|
||||
"token": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyPassword",
|
||||
payload: test.GenericPayload{
|
||||
"token": "42deb737-fa9c-4e9e-bdce-e33b829c72f7",
|
||||
"password": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password/complete", tt.payload, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
256
internal/api/handlers/auth/post_forgot_password_test.go
Normal file
256
internal/api/handlers/auth/post_forgot_password_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostForgotPasswordSuccess(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Auth.PasswordResetTokenReuseDuration = 120 * time.Second
|
||||
config.Auth.PasswordResetTokenDebounceDuration = 60 * time.Second
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
passwordResetToken, err := fix.User1.PasswordResetTokens().One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
require.NotNil(t, mail)
|
||||
assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetToken.Token))
|
||||
|
||||
// retrying should not send a new mail because of the debounce time
|
||||
{
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
sentMails := test.GetSentMails(t, s.Mailer)
|
||||
assert.Len(t, sentMails, 1)
|
||||
}
|
||||
|
||||
// CreatedAt of token exceeds debounce time, retrying should send a new mail
|
||||
// but with the same token as the reuse duration has not passed yet
|
||||
{
|
||||
passwordResetToken.CreatedAt = s.Clock.Now().Add(-s.Config.Auth.PasswordResetTokenDebounceDuration)
|
||||
_, err = passwordResetToken.Update(ctx, s.DB, boil.Whitelist(models.PasswordResetTokenColumns.CreatedAt))
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
sentMails := test.GetSentMails(t, s.Mailer)
|
||||
require.Len(t, sentMails, 2)
|
||||
|
||||
passwordResetTokens, err := fix.User1.PasswordResetTokens().All(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, passwordResetTokens, 1)
|
||||
for _, mail := range sentMails {
|
||||
assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token))
|
||||
}
|
||||
}
|
||||
|
||||
// CreatedAt of token exceeds reuse time, retrying should send a new mail with a new token
|
||||
{
|
||||
passwordResetToken.CreatedAt = s.Clock.Now().Add(-s.Config.Auth.PasswordResetTokenReuseDuration)
|
||||
_, err = passwordResetToken.Update(ctx, s.DB, boil.Whitelist(models.PasswordResetTokenColumns.CreatedAt))
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
sentMails := test.GetSentMails(t, s.Mailer)
|
||||
require.Len(t, sentMails, 3)
|
||||
|
||||
passwordResetTokens, err := fix.User1.PasswordResetTokens(
|
||||
db.OrderBy(types.OrderDirDesc, models.PasswordResetTokenColumns.CreatedAt),
|
||||
).All(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, passwordResetTokens, 2)
|
||||
|
||||
assert.Contains(t, string(sentMails[2].HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token))
|
||||
}
|
||||
|
||||
// Token validity is expired, retrying should send a new mail with a new token
|
||||
{
|
||||
_, err = models.PasswordResetTokens().UpdateAll(ctx, s.DB, models.M{
|
||||
models.PasswordResetTokenColumns.ValidUntil: s.Clock.Now().Add(-1 * time.Second),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
sentMails := test.GetSentMails(t, s.Mailer)
|
||||
require.Len(t, sentMails, 4)
|
||||
|
||||
passwordResetTokens, err := fix.User1.PasswordResetTokens(
|
||||
db.OrderBy(types.OrderDirDesc, models.PasswordResetTokenColumns.CreatedAt),
|
||||
).All(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, passwordResetTokens, 3)
|
||||
|
||||
assert.Contains(t, string(sentMails[3].HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetTokens[0].Token))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordSuccessLowercaseTrimWhitespaces(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fmt.Sprintf(" %s ", strings.ToUpper(fix.User1.Username.String)),
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
passwordResetToken, err := fix.User1.PasswordResetTokens().One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
require.NotNil(t, mail)
|
||||
assert.Contains(t, string(mail.HTML), fmt.Sprintf("http://localhost:3000/set-new-password?token=%s", passwordResetToken.Token))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordUnknownUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"username": "definitelydoesnotexist@example.com",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), cnt)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
assert.Nil(t, mail)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordDeactivatedUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.UserDeactivated.Username,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), cnt)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
assert.Nil(t, mail)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordUserWithoutPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User2.Username,
|
||||
}
|
||||
|
||||
fix.User2.Password = null.String{}
|
||||
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAff)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", payload, nil)
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), cnt)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
assert.Nil(t, mail)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostForgotPasswordBadRequest(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "MissingUsername",
|
||||
payload: test.GenericPayload{},
|
||||
},
|
||||
{
|
||||
name: "EmptyUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "definitely not an email",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/forgot-password", tt.payload, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
|
||||
cnt, err := models.PasswordResetTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), cnt)
|
||||
|
||||
mail := test.GetLastSentMail(t, s.Mailer)
|
||||
assert.Nil(t, mail)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
39
internal/api/handlers/auth/post_login.go
Normal file
39
internal/api/handlers/auth/post_login.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostLoginRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/login", postLoginHandler(s))
|
||||
}
|
||||
|
||||
func postLoginHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostLoginPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.Auth.Login(ctx, dto.LoginRequest{
|
||||
Username: dto.NewUsername(body.Username.String()),
|
||||
Password: swag.StringValue(body.Password),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to authenticate user")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
|
||||
}
|
||||
}
|
||||
179
internal/api/handlers/auth/post_login_test.go
Normal file
179
internal/api/handlers/auth/post_login_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostLoginSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginSuccessLowercaseTrimWhitespaces(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fmt.Sprintf(" %s ", strings.ToUpper(fix.User1.Username.String)),
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginInvalidCredentials(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
"password": "not my password",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginUnknownUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
payload := test.GenericPayload{
|
||||
"username": "definitelydoesnotexist@example.com",
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginDeactivatedUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.UserDeactivated.Username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginUserWithoutPassword(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User2.Username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
fix.User2.Password = null.String{}
|
||||
rowsAff, err := fix.User2.Update(t.Context(), s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rowsAff)
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLoginBadRequest(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "InvalidUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "definitely not an email",
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MissingUsername",
|
||||
payload: test.GenericPayload{
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MissingPassword",
|
||||
payload: test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "",
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyPassword",
|
||||
payload: test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
"password": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", tt.payload, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
45
internal/api/handlers/auth/post_logout.go
Normal file
45
internal/api/handlers/auth/post_logout.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostLogoutRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/logout", postLogoutHandler(s))
|
||||
}
|
||||
|
||||
func postLogoutHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostLogoutPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request := dto.LogoutRequest{
|
||||
AccessToken: *auth.AccessTokenFromEchoContext(c),
|
||||
}
|
||||
|
||||
if len(body.RefreshToken.String()) > 0 {
|
||||
request.RefreshToken = null.StringFrom(body.RefreshToken.String())
|
||||
}
|
||||
|
||||
err := s.Auth.Logout(ctx, request)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to logout user")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
129
internal/api/handlers/auth/post_logout_test.go
Normal file
129
internal/api/handlers/auth/post_logout_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostLogoutSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogoutSuccessWithRefreshToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": fix.User1RefreshToken1.Token,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogoutSuccessWithUnknownRefreshToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": "93d8ccd0-be30-4661-a428-cbe74e1a3ffe",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogoutInvalidRefreshToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": "not my refresh token",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
|
||||
err := fix.User1AccessToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogoutError(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expectedError *httperrors.HTTPError
|
||||
headers http.Header
|
||||
}{
|
||||
{
|
||||
name: "InvalidAuthToken",
|
||||
expectedError: middleware.ErrBadRequestMalformedToken,
|
||||
headers: test.HeadersWithAuth(t, "not my auth token"),
|
||||
},
|
||||
{
|
||||
name: "UnknownAuthToken",
|
||||
expectedError: httperrors.NewFromEcho(echo.ErrUnauthorized),
|
||||
headers: test.HeadersWithAuth(t, "25e8630e-9a41-4f38-8339-373f0c203cef"),
|
||||
},
|
||||
{
|
||||
name: "MissingAuthToken",
|
||||
expectedError: httperrors.NewFromEcho(echo.ErrUnauthorized),
|
||||
headers: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/logout", nil, tt.headers)
|
||||
test.RequireHTTPError(t, res, tt.expectedError)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
37
internal/api/handlers/auth/post_refresh.go
Normal file
37
internal/api/handlers/auth/post_refresh.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostRefreshRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/refresh", postRefreshHandler(s))
|
||||
}
|
||||
|
||||
func postRefreshHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostRefreshPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.Auth.Refresh(ctx, dto.RefreshRequest{
|
||||
RefreshToken: body.RefreshToken.String(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to refresh tokens")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, result.ToTypes())
|
||||
}
|
||||
}
|
||||
108
internal/api/handlers/auth/post_refresh_test.go
Normal file
108
internal/api/handlers/auth/post_refresh_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostRefreshSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": fix.User1RefreshToken1.Token,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEqual(t, fix.User1AccessToken1.Token, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.NotEqual(t, fix.User1RefreshToken1.Token, response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
|
||||
err := fix.User1RefreshToken1.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRefreshUnknownToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": "c094e933-e5f0-4ece-9c10-914f3122cdb6",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrUnauthorized))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRefreshDeactivatedUser(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"refresh_token": fix.UserDeactivatedRefreshToken1.Token,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrForbiddenUserDeactivated)
|
||||
|
||||
err := fix.UserDeactivatedRefreshToken1.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRefreshBadRequest(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "MissingRefreshToken",
|
||||
payload: test.GenericPayload{},
|
||||
},
|
||||
{
|
||||
name: "EmptyRefreshToken",
|
||||
payload: test.GenericPayload{
|
||||
"refresh_token": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidToken",
|
||||
payload: test.GenericPayload{
|
||||
"refresh_token": "not a valid token",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/refresh", tt.payload, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
72
internal/api/handlers/auth/post_register.go
Normal file
72
internal/api/handlers/auth/post_register.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/url"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PostRegisterRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Auth.POST("/register", postRegisterHandler(s))
|
||||
}
|
||||
|
||||
func postRegisterHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PostRegisterPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username := dto.NewUsername(body.Username.String())
|
||||
|
||||
result, err := s.Auth.Register(ctx, dto.RegisterRequest{
|
||||
Username: username,
|
||||
Password: swag.StringValue(body.Password),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to register user")
|
||||
return err
|
||||
}
|
||||
|
||||
if !result.RequiresConfirmation {
|
||||
loginResult, err := s.Auth.Login(ctx, dto.LoginRequest{
|
||||
Username: username,
|
||||
Password: swag.StringValue(body.Password),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to authenticate user after registration")
|
||||
return err
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusOK, loginResult.ToTypes())
|
||||
}
|
||||
|
||||
if result.ConfirmationToken.Valid {
|
||||
confirmationLink, err := url.ConfirmationDeeplinkURL(s.Config, result.ConfirmationToken.String)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to generate confirmation link")
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.Mailer.SendAccountConfirmation(ctx, username.String(), dto.ConfirmatioNotificationPayload{
|
||||
ConfirmationLink: confirmationLink.String(),
|
||||
}); err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to send confirmation email")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return util.ValidateAndReturn(c, http.StatusAccepted, &types.RegisterResponse{
|
||||
RequiresConfirmation: swag.Bool(result.RequiresConfirmation),
|
||||
})
|
||||
}
|
||||
}
|
||||
334
internal/api/handlers/auth/post_register_test.go
Normal file
334
internal/api/handlers/auth/post_register_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/url"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostRegisterSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
now := time.Date(2025, 2, 5, 11, 42, 30, 0, time.UTC)
|
||||
test.SetMockClock(t, s, now)
|
||||
|
||||
username := "usernew@example.com"
|
||||
payload := test.GenericPayload{
|
||||
"username": username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(null.StringFrom(username)),
|
||||
qm.Load(models.UserRels.AppUserProfile),
|
||||
qm.Load(models.UserRels.AccessTokens),
|
||||
qm.Load(models.UserRels.RefreshTokens),
|
||||
).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, null.StringFrom(username), user.Username)
|
||||
assert.True(t, user.LastAuthenticatedAt.Valid)
|
||||
assert.Equal(t, now, user.LastAuthenticatedAt.Time)
|
||||
assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes)
|
||||
|
||||
assert.NotNil(t, user.R.AppUserProfile)
|
||||
assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid)
|
||||
|
||||
assert.Len(t, user.R.AccessTokens, 1)
|
||||
assert.Equal(t, strfmt.UUID4(user.R.AccessTokens[0].Token), *response.AccessToken)
|
||||
assert.Len(t, user.R.RefreshTokens, 1)
|
||||
assert.Equal(t, strfmt.UUID4(user.R.RefreshTokens[0].Token), *response.RefreshToken)
|
||||
|
||||
res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res2.Result().StatusCode)
|
||||
|
||||
var response2 types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res2, &response2)
|
||||
|
||||
assert.NotEmpty(t, response2.AccessToken)
|
||||
assert.NotEqual(t, response.AccessToken, *response2.AccessToken)
|
||||
assert.NotEmpty(t, response2.RefreshToken)
|
||||
assert.NotEqual(t, response.RefreshToken, *response2.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response2.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response2.TokenType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRegisterWithConfirmationSuccess(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Auth.RegistrationRequiresConfirmation = true
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
username := "usernew-with-confirmation@example.com"
|
||||
payload := test.GenericPayload{
|
||||
"username": username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, res.Result().StatusCode)
|
||||
|
||||
var response types.RegisterResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.True(t, swag.BoolValue(response.RequiresConfirmation))
|
||||
|
||||
// expect the user to be normally created
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(null.StringFrom(username)),
|
||||
qm.Load(models.UserRels.AppUserProfile),
|
||||
qm.Load(models.UserRels.AccessTokens),
|
||||
qm.Load(models.UserRels.RefreshTokens),
|
||||
qm.Load(models.UserRels.ConfirmationTokens),
|
||||
).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, null.StringFrom(username), user.Username)
|
||||
assert.True(t, user.LastAuthenticatedAt.Valid)
|
||||
assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes)
|
||||
assert.False(t, user.IsActive)
|
||||
assert.True(t, user.RequiresConfirmation)
|
||||
|
||||
assert.NotNil(t, user.R.AppUserProfile)
|
||||
assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid)
|
||||
|
||||
// expect the user to have no access or refresh tokens
|
||||
assert.Empty(t, user.R.AccessTokens)
|
||||
assert.Empty(t, user.R.RefreshTokens)
|
||||
require.Len(t, user.R.ConfirmationTokens, 1)
|
||||
confirmationToken := user.R.ConfirmationTokens[0]
|
||||
|
||||
// expect the login to fail
|
||||
res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
test.RequireHTTPError(t, res2, httperrors.ErrForbiddenUserDeactivated)
|
||||
|
||||
// expect the confirmation email to be sent
|
||||
mails := test.GetSentMails(t, s.Mailer)
|
||||
require.Len(t, mails, 1)
|
||||
|
||||
mail := mails[0]
|
||||
expectedConfirmationLink, err := url.ConfirmationDeeplinkURL(s.Config, confirmationToken.Token)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, username, mail.To[0])
|
||||
assert.Contains(t, string(mail.HTML), expectedConfirmationLink.String())
|
||||
|
||||
// directly register again should trigger the debounce
|
||||
// and not create a new confirmation token
|
||||
registerAgainRes := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
require.Equal(t, http.StatusAccepted, registerAgainRes.Result().StatusCode)
|
||||
|
||||
var registerAgainResponse types.RegisterResponse
|
||||
test.ParseResponseAndValidate(t, registerAgainRes, ®isterAgainResponse)
|
||||
|
||||
// expect the confirmation to be required
|
||||
assert.True(t, swag.BoolValue(registerAgainResponse.RequiresConfirmation))
|
||||
|
||||
confirmationTokenCount, err := user.ConfirmationTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, 1, confirmationTokenCount)
|
||||
|
||||
// register later again
|
||||
test.SetMockClock(t, s, s.Clock.Now().Add(config.Auth.ConfirmationTokenDebounceDuration+time.Second))
|
||||
|
||||
registerLaterAgainRes := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
require.Equal(t, http.StatusAccepted, registerLaterAgainRes.Result().StatusCode)
|
||||
|
||||
var registerLaterAgainResponse types.RegisterResponse
|
||||
test.ParseResponseAndValidate(t, registerLaterAgainRes, ®isterLaterAgainResponse)
|
||||
assert.True(t, swag.BoolValue(registerLaterAgainResponse.RequiresConfirmation))
|
||||
|
||||
confirmationTokens, err := models.ConfirmationTokens(
|
||||
models.ConfirmationTokenWhere.UserID.EQ(user.ID),
|
||||
db.OrderBy(types.OrderDirDesc, models.ConfirmationTokenColumns.CreatedAt),
|
||||
).All(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, confirmationTokens, 2)
|
||||
|
||||
lastSentMail := test.GetLastSentMail(t, s.Mailer)
|
||||
require.NotNil(t, lastSentMail)
|
||||
|
||||
expectedConfirmationLink, err = url.ConfirmationDeeplinkURL(s.Config, confirmationTokens[0].Token)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, username, lastSentMail.To[0])
|
||||
assert.Contains(t, string(lastSentMail.HTML), expectedConfirmationLink.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRegisterSuccessLowercaseTrimWhitespaces(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
username := " USERNEW@example.com "
|
||||
usernameLowerTrimmed := "usernew@example.com"
|
||||
payload := test.GenericPayload{
|
||||
"username": username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
"name": "Trim Whitespaces",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.NotEmpty(t, response.AccessToken)
|
||||
assert.NotEmpty(t, response.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response.TokenType)
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(null.StringFrom(usernameLowerTrimmed)),
|
||||
qm.Load(models.UserRels.AppUserProfile),
|
||||
qm.Load(models.UserRels.AccessTokens),
|
||||
qm.Load(models.UserRels.RefreshTokens),
|
||||
).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, null.StringFrom(usernameLowerTrimmed), user.Username)
|
||||
assert.True(t, user.LastAuthenticatedAt.Valid)
|
||||
assert.WithinDuration(t, s.Clock.Now(), user.LastAuthenticatedAt.Time, time.Second*10)
|
||||
assert.EqualValues(t, s.Config.Auth.DefaultUserScopes, user.Scopes)
|
||||
|
||||
assert.NotNil(t, user.R.AppUserProfile)
|
||||
assert.False(t, user.R.AppUserProfile.LegalAcceptedAt.Valid)
|
||||
|
||||
assert.Len(t, user.R.AccessTokens, 1)
|
||||
assert.Equal(t, strfmt.UUID4(user.R.AccessTokens[0].Token), *response.AccessToken)
|
||||
assert.Len(t, user.R.RefreshTokens, 1)
|
||||
assert.Equal(t, strfmt.UUID4(user.R.RefreshTokens[0].Token), *response.RefreshToken)
|
||||
|
||||
res2 := test.PerformRequest(t, s, "POST", "/api/v1/auth/login", payload, nil)
|
||||
assert.Equal(t, http.StatusOK, res2.Result().StatusCode)
|
||||
|
||||
var response2 types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res2, &response2)
|
||||
|
||||
assert.NotEmpty(t, response2.AccessToken)
|
||||
assert.NotEqual(t, response.AccessToken, *response2.AccessToken)
|
||||
assert.NotEmpty(t, response2.RefreshToken)
|
||||
assert.NotEqual(t, response.RefreshToken, *response2.RefreshToken)
|
||||
assert.Equal(t, int64(s.Config.Auth.AccessTokenValidity.Seconds()), *response2.ExpiresIn)
|
||||
assert.Equal(t, auth.TokenTypeBearer, *response2.TokenType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRegisterAlreadyExists(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
fix := fixtures.Fixtures()
|
||||
payload := test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", payload, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.ErrConflictUserAlreadyExists)
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(fix.User1.Username),
|
||||
qm.Load(models.UserRels.AppUserProfile),
|
||||
qm.Load(models.UserRels.AccessTokens),
|
||||
qm.Load(models.UserRels.RefreshTokens),
|
||||
).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, fix.User1.ID)
|
||||
|
||||
assert.NotNil(t, user.R.AppUserProfile)
|
||||
assert.Len(t, user.R.AccessTokens, 1)
|
||||
assert.Len(t, user.R.RefreshTokens, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostRegisterBadRequest(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload test.GenericPayload
|
||||
}{
|
||||
{
|
||||
name: "MissingUsername",
|
||||
payload: test.GenericPayload{
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MissingPassword",
|
||||
payload: test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "definitely not an email",
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyUsername",
|
||||
payload: test.GenericPayload{
|
||||
"username": "",
|
||||
"password": fixtures.PlainTestUserPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyPassword",
|
||||
payload: test.GenericPayload{
|
||||
"username": fix.User1.Username,
|
||||
"password": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := test.PerformRequest(t, s, "POST", "/api/v1/auth/register", tt.payload, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
|
||||
var response httperrors.HTTPValidationError
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
test.Snapshoter.Save(t, response)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
55
internal/api/handlers/common/get_healthy.go
Normal file
55
internal/api/handlers/common/get_healthy.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
// We use 521 to indicate an error state
|
||||
// same as Cloudflare: https://support.cloudflare.com/hc/en-us/articles/115003011431#521error
|
||||
httpStatusDown = 521
|
||||
)
|
||||
|
||||
func GetHealthyRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.Management.GET("/healthy", getHealthyHandler(s))
|
||||
}
|
||||
|
||||
// Heathly check (= liveness)
|
||||
// Returns an human readable string about the current service status.
|
||||
// In addition to readiness probes, it performs actual write probes.
|
||||
// Note that /-/healthy is private (shielded by the mgmt-secret) as it may expose sensitive information about your service.
|
||||
// Structured upon https://prometheus.io/docs/prometheus/latest/management_api/
|
||||
func getHealthyHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if !s.Ready() {
|
||||
return c.String(httpStatusDown, "Not ready.")
|
||||
}
|
||||
|
||||
var str strings.Builder
|
||||
fmt.Fprintln(&str, "Ready.")
|
||||
|
||||
// General Timeout and associated context.
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), s.Config.Management.LivenessTimeout)
|
||||
defer cancel()
|
||||
|
||||
healthyStr, errs := ProbeLiveness(ctx, s.DB, s.Config.Management.ProbeWriteablePathsAbs, s.Config.Management.ProbeWriteableTouchfile)
|
||||
str.WriteString(healthyStr)
|
||||
|
||||
// Finally return the health status according to the seen states
|
||||
if ctx.Err() != nil || len(errs) != 0 {
|
||||
fmt.Fprintln(&str, "Probes failed.")
|
||||
return c.String(httpStatusDown, str.String())
|
||||
}
|
||||
|
||||
fmt.Fprintln(&str, "Probes succeeded.")
|
||||
|
||||
return c.String(http.StatusOK, str.String())
|
||||
}
|
||||
}
|
||||
110
internal/api/handlers/common/get_healthy_test.go
Normal file
110
internal/api/handlers/common/get_healthy_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetHealthySuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// explicitly set touchfile that no other test has (so we can explicitly remove it beforehand.)
|
||||
s.Config.Management.ProbeWriteableTouchfile = ".healthy-test"
|
||||
|
||||
for _, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs {
|
||||
os.Remove(path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile))
|
||||
|
||||
// also remove after test completion.
|
||||
defer os.Remove(path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile))
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
require.Contains(t, res.Body.String(), "seq_health=1")
|
||||
|
||||
firstTouchTime := make([]time.Time, len(s.Config.Management.ProbeWriteablePathsAbs))
|
||||
|
||||
// expect a new touchfiles were written
|
||||
for i, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs {
|
||||
filePath := path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile)
|
||||
stat, err := os.Stat(filePath)
|
||||
require.NoErrorf(t, err, "Expected to have %v", filePath)
|
||||
firstTouchTime[i] = stat.ModTime()
|
||||
}
|
||||
|
||||
res = test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
require.Contains(t, res.Body.String(), "seq_health=2")
|
||||
|
||||
// expect touchfiles modTime was updated
|
||||
for i, writeablePath := range s.Config.Management.ProbeWriteablePathsAbs {
|
||||
filePath := path.Join(writeablePath, s.Config.Management.ProbeWriteableTouchfile)
|
||||
stat, err := os.Stat(filePath)
|
||||
require.NoErrorf(t, err, "Expected to have %v", filePath)
|
||||
|
||||
assert.NotEqual(t, firstTouchTime[i], stat.ModTime())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyNoAuth(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy", nil, nil)
|
||||
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyWrongAuth(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret=i-have-no-idea-about-the-pass", nil, nil)
|
||||
require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyDBPingError(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// forcefully close the DB
|
||||
s.DB.Close()
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyDBSeqError(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// forcefully remove the sequence
|
||||
if _, err := s.DB.Exec("DROP SEQUENCE seq_health;"); err != nil {
|
||||
t.Fatal(err, "was unable to drop sequence seq_health")
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyMountError(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
s.Config.Management.ProbeWriteablePathsAbs = []string{"/this/path/does/not/exist"}
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetHealthyNotReady(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// forcefully remove an initialized component to check if ready state works
|
||||
s.Mailer = nil
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/healthy?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
40
internal/api/handlers/common/get_ready.go
Normal file
40
internal/api/handlers/common/get_ready.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetReadyRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.Management.GET("/ready", getReadyHandler(s))
|
||||
}
|
||||
|
||||
// Readiness check
|
||||
// This endpoint returns 200 when our Service is ready to serve traffic (i.e. respond to queries).
|
||||
// Does read-only probes apart from the general server ready state.
|
||||
// Note that /-/ready is typically public (and not shielded by a mgmt-secret), we thus prevent information leakage here and only return `"Ready."`.
|
||||
// Structured upon https://prometheus.io/docs/prometheus/latest/management_api/
|
||||
func getReadyHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if !s.Ready() {
|
||||
return c.String(httpStatusDown, "Not ready.")
|
||||
}
|
||||
|
||||
// General Timeout and associated context.
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), s.Config.Management.ReadinessTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, errs := ProbeReadiness(ctx, s.DB, s.Config.Management.ProbeWriteablePathsAbs)
|
||||
|
||||
// Finally return the health status according to the seen states
|
||||
if ctx.Err() != nil || len(errs) != 0 {
|
||||
return c.String(httpStatusDown, "Not ready.")
|
||||
}
|
||||
|
||||
return c.String(http.StatusOK, "Ready.")
|
||||
}
|
||||
}
|
||||
41
internal/api/handlers/common/get_ready_test.go
Normal file
41
internal/api/handlers/common/get_ready_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetReadyReadiness(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
require.Equal(t, "Ready.", res.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetReadyReadinessBroken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// forcefully remove an initialized component to check if ready state works
|
||||
s.Mailer = nil
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
require.Equal(t, "Not ready.", res.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetReadyDBBrokenNotReady(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// forcefully remove pg
|
||||
err := s.DB.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
|
||||
require.Equal(t, 521, res.Result().StatusCode)
|
||||
require.Equal(t, "Not ready.", res.Body.String())
|
||||
})
|
||||
}
|
||||
20
internal/api/handlers/common/get_swagger.go
Normal file
20
internal/api/handlers/common/get_swagger.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetSwaggerRoute(s *api.Server) *echo.Route {
|
||||
// ---
|
||||
// Serve generated swagger.yml file statically at /swagger.yml
|
||||
// hack: not attached to group - can go away after echo/group.go .File and .Static actually return the *echo.Route
|
||||
// see https://github.com/labstack/echo/issues/1595
|
||||
// return s.Router.Root.File("swagger.yml", filepath.Join(s.Config.Echo.APIBaseDirAbs, "swagger.yml"))
|
||||
// we explicitly enforce a no-cache directive on any requests to it.
|
||||
return s.Echo.File("/swagger.yml", filepath.Join(s.Config.Paths.APIBaseDirAbs, "swagger.yml"), middleware.NoCache())
|
||||
}
|
||||
32
internal/api/handlers/common/get_swagger_test.go
Normal file
32
internal/api/handlers/common/get_swagger_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSwaggerYAMLRetrieval(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/swagger.yml", nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
// caching: ensure this call is always uncached for browsers
|
||||
assert.Equal(t, "no-cache, private, max-age=0", res.Header().Get("Cache-Control"))
|
||||
assert.Equal(t, "Thu, 01 Jan 1970 00:00:00 UTC", res.Header().Get("Expires"))
|
||||
assert.Equal(t, "0", res.Header().Get("X-Accel-Expires"))
|
||||
assert.Equal(t, "no-cache", res.Header().Get("Pragma"))
|
||||
|
||||
// caching: unset
|
||||
assert.Empty(t, res.Header().Get("ETag"))
|
||||
assert.Empty(t, res.Header().Get("If-Modified-Since"))
|
||||
assert.Empty(t, res.Header().Get("If-Match"))
|
||||
assert.Empty(t, res.Header().Get("If-None-Match"))
|
||||
assert.Empty(t, res.Header().Get("If-Range"))
|
||||
assert.Empty(t, res.Header().Get("If-Unmodified-Since"))
|
||||
})
|
||||
}
|
||||
21
internal/api/handlers/common/get_version.go
Normal file
21
internal/api/handlers/common/get_version.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetVersionRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.Management.GET("/version", getVersionHandler(s))
|
||||
}
|
||||
|
||||
// Returns the version and build date baked into the binary.
|
||||
func getVersionHandler(_ *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, config.GetFormattedBuildArgs())
|
||||
}
|
||||
}
|
||||
33
internal/api/handlers/common/get_version_test.go
Normal file
33
internal/api/handlers/common/get_version_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package common_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetVersion(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/-/version?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
require.Equal(t, "build.local/misses/ldflags @ < 40 chars git commit hash via ldflags > (1970-01-01T00:00:00+00:00)", res.Body.String()) // build args are not injected during test time.
|
||||
|
||||
// caching: ensure this call is always uncached for browsers
|
||||
assert.Equal(t, "no-cache, private, max-age=0", res.Header().Get("Cache-Control"))
|
||||
assert.Equal(t, "Thu, 01 Jan 1970 00:00:00 UTC", res.Header().Get("Expires"))
|
||||
assert.Equal(t, "0", res.Header().Get("X-Accel-Expires"))
|
||||
assert.Equal(t, "no-cache", res.Header().Get("Pragma"))
|
||||
|
||||
// caching: unset
|
||||
assert.Empty(t, res.Header().Get("ETag"))
|
||||
assert.Empty(t, res.Header().Get("If-Modified-Since"))
|
||||
assert.Empty(t, res.Header().Get("If-Match"))
|
||||
assert.Empty(t, res.Header().Get("If-None-Match"))
|
||||
assert.Empty(t, res.Header().Get("If-Range"))
|
||||
assert.Empty(t, res.Header().Get("If-Unmodified-Since"))
|
||||
})
|
||||
}
|
||||
226
internal/api/handlers/common/probes.go
Normal file
226
internal/api/handlers/common/probes.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func ProbeReadiness(ctx context.Context, database *sql.DB, writeablePaths []string) (string, []error) {
|
||||
var str strings.Builder
|
||||
|
||||
// slice collects all errors from probes
|
||||
errs := make([]error, 0, 1+len(writeablePaths))
|
||||
|
||||
// DB readable?
|
||||
dbPingStr, dbPingErr := probeDatabasePingable(ctx, database)
|
||||
str.WriteString(dbPingStr)
|
||||
|
||||
if dbPingErr != nil {
|
||||
errs = append(errs, dbPingErr)
|
||||
}
|
||||
|
||||
// FS (potentially) writeable?
|
||||
for _, writeablePath := range writeablePaths {
|
||||
fsPermStr, fsPermErr := probePathWriteablePermission(ctx, writeablePath)
|
||||
str.WriteString(fsPermStr)
|
||||
|
||||
if fsPermErr != nil {
|
||||
errs = append(errs, fsPermErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Feel free to add additional probes here...
|
||||
|
||||
return str.String(), errs
|
||||
}
|
||||
|
||||
func ProbeLiveness(ctx context.Context, database *sql.DB, writeablePaths []string, touch string) (string, []error) {
|
||||
// fail immediately if any readiness probes above have already failed.
|
||||
readinessProbeStr, readinessProbeErrs := ProbeReadiness(ctx, database, writeablePaths)
|
||||
|
||||
if len(readinessProbeErrs) != 0 {
|
||||
return readinessProbeStr, readinessProbeErrs
|
||||
}
|
||||
|
||||
var str strings.Builder
|
||||
|
||||
// include previous readiness probe results in final string
|
||||
str.WriteString(readinessProbeStr)
|
||||
|
||||
// slice collects all errors from probes
|
||||
errs := make([]error, 0, 1+len(writeablePaths))
|
||||
|
||||
// DB writeable?
|
||||
dbHealthStr, dbHealthErr := probeDatabaseNextHealthSequence(ctx, database)
|
||||
str.WriteString(dbHealthStr)
|
||||
|
||||
if dbHealthErr != nil {
|
||||
errs = append(errs, dbHealthErr)
|
||||
}
|
||||
|
||||
// FS writeable?
|
||||
for _, writeablePath := range writeablePaths {
|
||||
fsTouchStr, fsTouchErr := probePathWriteableTouch(ctx, writeablePath, touch)
|
||||
str.WriteString(fsTouchStr)
|
||||
if fsTouchErr != nil {
|
||||
errs = append(errs, fsTouchErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Feel free to add additional probes here...
|
||||
|
||||
return str.String(), errs
|
||||
}
|
||||
|
||||
// FS (especially hard mounted NFS paths) or PostgreSQL calls may be blocking or running for too long and thus need to run detached
|
||||
// We additionally want them to timeout (e.g. useful for hard mounted NFS paths)
|
||||
// Typically a any context used here will already have a deadline associated
|
||||
// If not we will explicitly return a short one here.
|
||||
func ensureProbeDeadlineFromContext(ctx context.Context) time.Time {
|
||||
ctxDeadline, hasDeadline := ctx.Deadline()
|
||||
if !hasDeadline {
|
||||
ctxDeadline = time.Now().Add(1 * time.Second)
|
||||
}
|
||||
|
||||
return ctxDeadline
|
||||
}
|
||||
|
||||
func probeDatabasePingable(ctx context.Context, database *sql.DB) (string, error) {
|
||||
var str strings.Builder
|
||||
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
|
||||
dbPingStart := time.Now()
|
||||
|
||||
var dbPingWg sync.WaitGroup
|
||||
var dbErr error
|
||||
|
||||
dbPingWg.Add(1)
|
||||
go func() {
|
||||
dbErr = database.PingContext(ctx)
|
||||
dbPingWg.Done()
|
||||
}()
|
||||
|
||||
if err := util.WaitTimeout(&dbPingWg, time.Until(ctxDeadline)); err != nil {
|
||||
fmt.Fprintf(&str, "Probe db: Ping deadline after %s, error=%v.\n", time.Since(dbPingStart), err.Error())
|
||||
return str.String(), err
|
||||
}
|
||||
|
||||
if dbErr != nil {
|
||||
fmt.Fprintf(&str, "Probe db: Ping errored after %s, error=%v.\n", time.Since(dbPingStart), dbErr.Error())
|
||||
return str.String(), dbErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(&str, "Probe db: Ping succeeded in %s.\n", time.Since(dbPingStart))
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
func probeDatabaseNextHealthSequence(ctx context.Context, database *sql.DB) (string, error) {
|
||||
var str strings.Builder
|
||||
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
|
||||
dbWriteStart := time.Now()
|
||||
|
||||
var seqVal int
|
||||
var dbWriteWg sync.WaitGroup
|
||||
var dbErr error
|
||||
|
||||
dbWriteWg.Add(1)
|
||||
go func() {
|
||||
dbErr = database.QueryRowContext(ctx, "SELECT nextval('seq_health');").Scan(&seqVal)
|
||||
dbWriteWg.Done()
|
||||
}()
|
||||
|
||||
if err := util.WaitTimeout(&dbWriteWg, time.Until(ctxDeadline)); err != nil {
|
||||
fmt.Fprintf(&str, "Probe db: Next health sequence deadline after %s, error=%v.\n", time.Since(dbWriteStart), err.Error())
|
||||
return str.String(), err
|
||||
}
|
||||
|
||||
if dbErr != nil {
|
||||
fmt.Fprintf(&str, "Probe db: Next health sequence errored after %s, error=%v.\n", time.Since(dbWriteStart), dbErr.Error())
|
||||
return str.String(), dbErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(&str, "Probe db: Next health sequence succeeded in %s, seq_health=%v.\n", time.Since(dbWriteStart), seqVal)
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
func probePathWriteablePermission(ctx context.Context, writeablePath string) (string, error) {
|
||||
var str strings.Builder
|
||||
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
|
||||
fsWriteStart := time.Now()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': W_OK check cancelled after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), ctx.Err())
|
||||
return str.String(), ctx.Err()
|
||||
}
|
||||
|
||||
var fsWriteWg sync.WaitGroup
|
||||
var fsWriteErr error
|
||||
fsWriteWg.Add(1)
|
||||
go func(wp string) {
|
||||
fsWriteErr = unix.Access(wp, unix.W_OK)
|
||||
fsWriteWg.Done()
|
||||
}(writeablePath)
|
||||
|
||||
if err := util.WaitTimeout(&fsWriteWg, time.Until(ctxDeadline)); err != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': W_OK check deadline after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), err)
|
||||
return str.String(), err
|
||||
}
|
||||
|
||||
if fsWriteErr != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': W_OK check errored after %s, error=%v.\n", writeablePath, time.Since(fsWriteStart), fsWriteErr.Error())
|
||||
return str.String(), fsWriteErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(&str, "Probe path '%s': W_OK check succeeded in %s.\n", writeablePath, time.Since(fsWriteStart))
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
func probePathWriteableTouch(ctx context.Context, writeablePath string, touch string) (string, error) {
|
||||
var str strings.Builder
|
||||
ctxDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
|
||||
fsTouchStart := time.Now()
|
||||
fsTouchNameAbs := path.Join(writeablePath, touch)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': Touch cancelled after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), ctx.Err())
|
||||
return str.String(), ctx.Err()
|
||||
}
|
||||
|
||||
var fsTouchWg sync.WaitGroup
|
||||
var fsTouchErr error
|
||||
var fsTouchModTime time.Time
|
||||
fsTouchWg.Add(1)
|
||||
go func(tn string) {
|
||||
fsTouchModTime, fsTouchErr = util.TouchFile(tn)
|
||||
fsTouchWg.Done()
|
||||
}(fsTouchNameAbs)
|
||||
|
||||
if err := util.WaitTimeout(&fsTouchWg, time.Until(ctxDeadline)); err != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': Touch deadline after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), err)
|
||||
return str.String(), err
|
||||
}
|
||||
|
||||
if fsTouchErr != nil {
|
||||
fmt.Fprintf(&str, "Probe path '%s': Touch errored after %s, error=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), fsTouchErr.Error())
|
||||
return str.String(), fsTouchErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(&str, "Probe path '%s': Touch succeeded in %s, modTime=%v.\n", fsTouchNameAbs, time.Since(fsTouchStart), fsTouchModTime.Unix())
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
70
internal/api/handlers/common/probes_internal_test.go
Normal file
70
internal/api/handlers/common/probes_internal_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// nolint:revive
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEnsureDeadline(t *testing.T) {
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithDeadline(t.Context(), deadline)
|
||||
defer cancel()
|
||||
|
||||
receivedDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
assert.Equal(t, deadline, receivedDeadline)
|
||||
}
|
||||
|
||||
func TestDummyDeadlineWithinOneSec(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
receivedDeadline := ensureProbeDeadlineFromContext(ctx)
|
||||
assert.WithinDuration(t, time.Now().Add(1*time.Second), receivedDeadline, 100*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestProbeDatabasePingableDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
_, err := probeDatabasePingable(ctx, &sql.DB{})
|
||||
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
|
||||
}
|
||||
|
||||
func TestProbeDatabaseNextHealthSequenceDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
_, err := probeDatabaseNextHealthSequence(ctx, &sql.DB{})
|
||||
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
|
||||
}
|
||||
|
||||
func TestProbePathWriteablePermissionContextDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
_, err := probePathWriteablePermission(ctx, "/any/thing")
|
||||
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
|
||||
}
|
||||
|
||||
func TestProbePathWriteableTouchContextDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
|
||||
defer cancel()
|
||||
|
||||
_, err := probePathWriteableTouch(ctx, "/any/thing", ".touch")
|
||||
assert.Truef(t, errors.Is(err, util.ErrWaitTimeout) || errors.Is(err, context.DeadlineExceeded), "err must be util.ErrWaitTimeout or context.DeadlineExceeded but is %v", err)
|
||||
}
|
||||
|
||||
func TestProbePathWriteableTouchInaccessable(t *testing.T) {
|
||||
_, err := probePathWriteableTouch(t.Context(), "/this/path/does/not/exist", ".touch")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
5
internal/api/handlers/constants/constants.go
Normal file
5
internal/api/handlers/constants/constants.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
RegistrationTokenParam = "registrationToken"
|
||||
)
|
||||
35
internal/api/handlers/handlers.go
Normal file
35
internal/api/handlers/handlers.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Code generated by go run -tags scripts scripts/handlers/gen_handlers.go; DO NOT EDIT.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/common"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/push"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/wellknown"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func AttachAllRoutes(s *api.Server) {
|
||||
// attach our routes
|
||||
s.Router.Routes = []*echo.Route{
|
||||
auth.DeleteUserAccountRoute(s),
|
||||
auth.GetCompleteRegisterRoute(s),
|
||||
auth.GetUserInfoRoute(s),
|
||||
auth.PostChangePasswordRoute(s),
|
||||
auth.PostCompleteRegisterRoute(s),
|
||||
auth.PostForgotPasswordCompleteRoute(s),
|
||||
auth.PostForgotPasswordRoute(s),
|
||||
auth.PostLoginRoute(s),
|
||||
auth.PostLogoutRoute(s),
|
||||
auth.PostRefreshRoute(s),
|
||||
auth.PostRegisterRoute(s),
|
||||
common.GetHealthyRoute(s),
|
||||
common.GetReadyRoute(s),
|
||||
common.GetSwaggerRoute(s),
|
||||
common.GetVersionRoute(s),
|
||||
push.PutUpdatePushTokenRoute(s),
|
||||
wellknown.GetAndroidDigitalAssetLinksRoute(s),
|
||||
wellknown.GetAppleAppSiteAssociationRoute(s),
|
||||
}
|
||||
}
|
||||
44
internal/api/handlers/push/put_update_push_token.go
Normal file
44
internal/api/handlers/push/put_update_push_token.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func PutUpdatePushTokenRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.APIV1Push.PUT("/token", putUpdatePushTokenHandler(s))
|
||||
}
|
||||
|
||||
func putUpdatePushTokenHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
user := auth.UserFromEchoContext(c)
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var body types.PutUpdatePushTokenPayload
|
||||
if err := util.BindAndValidateBody(c, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := s.Local.UpdatePushToken(ctx, dto.UpdatePushTokenRequest{
|
||||
User: *user,
|
||||
Token: swag.StringValue(body.NewToken),
|
||||
Provider: swag.StringValue(body.Provider),
|
||||
ExistingToken: null.StringFromPtr(body.OldToken),
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to update push token")
|
||||
return err
|
||||
}
|
||||
|
||||
return c.String(http.StatusOK, "Success")
|
||||
}
|
||||
}
|
||||
169
internal/api/handlers/push/put_update_push_token_test.go
Normal file
169
internal/api/handlers/push/put_update_push_token_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package push_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPutUpdatePushTokenSuccess(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
//nolint:gosec
|
||||
testToken := "869f6deb-73e6-4691-9d40-2a2a794006cf"
|
||||
testProvider := models.ProviderTypeFCM
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"newToken": testToken,
|
||||
"provider": testProvider,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, newToken.ID)
|
||||
assert.Equal(t, testToken, newToken.Token)
|
||||
assert.Equal(t, testProvider, newToken.Provider)
|
||||
assert.Equal(t, fix.User1.ID, newToken.UserID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPutUpdatePushTokenSuccessWithOldToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
//nolint:gosec
|
||||
oldToken := "6803ccb4-c91d-47b2-960e-291afa5e29cd"
|
||||
|
||||
oldPushToken := models.PushToken{
|
||||
Token: oldToken,
|
||||
Provider: models.ProviderTypeFCM,
|
||||
UserID: fix.User1.ID,
|
||||
}
|
||||
err := oldPushToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec
|
||||
testToken := "af55b6cf-1fb0-4bb7-960c-25268a5ce7c3"
|
||||
testProvider := models.ProviderTypeFCM
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"newToken": testToken,
|
||||
"provider": testProvider,
|
||||
"oldToken": oldToken,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
assert.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, newToken.ID)
|
||||
assert.Equal(t, testToken, newToken.Token)
|
||||
assert.Equal(t, testProvider, newToken.Provider)
|
||||
assert.Equal(t, fix.User1.ID, newToken.UserID)
|
||||
|
||||
err = oldPushToken.Reload(ctx, s.DB)
|
||||
require.ErrorIs(t, err, sql.ErrNoRows)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPutUpdatePushTokenWithDuplicateToken(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
//nolint:gosec
|
||||
oldToken := "6803ccb4-c91d-47b2-960e-291afa5e29cd"
|
||||
|
||||
oldPushToken := models.PushToken{
|
||||
Token: oldToken,
|
||||
Provider: models.ProviderTypeFCM,
|
||||
UserID: fix.User1.ID,
|
||||
}
|
||||
err := oldPushToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
testProvider := models.ProviderTypeFCM
|
||||
payload := test.GenericPayload{
|
||||
"newToken": oldToken,
|
||||
"provider": testProvider,
|
||||
"oldToken": oldToken,
|
||||
}
|
||||
|
||||
oldCnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrConflictPushToken)
|
||||
|
||||
err = oldPushToken.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldCnt, cnt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPutUpdatePushTokenWithOldTokenNotfound(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
//nolint:gosec
|
||||
oldToken := "cc08624a-b40d-4b8e-bbfe-f62aabb47592"
|
||||
|
||||
oldPushToken := models.PushToken{
|
||||
Token: oldToken,
|
||||
Provider: models.ProviderTypeFCM,
|
||||
UserID: fix.User1.ID,
|
||||
}
|
||||
err := oldPushToken.Insert(ctx, s.DB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
oldCnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec
|
||||
testToken := "8e4ad85f-cbb6-4ef3-a455-d9d8bd8917b3"
|
||||
testProvider := models.ProviderTypeFCM
|
||||
|
||||
payload := test.GenericPayload{
|
||||
"newToken": testToken,
|
||||
"provider": testProvider,
|
||||
"oldToken": "3199aa21-eb41-47dd-9287-338e9e88a5ae",
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "PUT", "/api/v1/push/token", payload, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
|
||||
test.RequireHTTPError(t, res, httperrors.ErrNotFoundOldPushToken)
|
||||
|
||||
newToken, err := models.PushTokens(models.PushTokenWhere.Token.EQ(testToken)).One(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, newToken.ID)
|
||||
assert.Equal(t, testToken, newToken.Token)
|
||||
assert.Equal(t, testProvider, newToken.Provider)
|
||||
assert.Equal(t, fix.User1.ID, newToken.UserID)
|
||||
|
||||
err = oldPushToken.Reload(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := fix.User1.PushTokens().Count(ctx, s.DB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldCnt+1, cnt)
|
||||
})
|
||||
}
|
||||
23
internal/api/handlers/wellknown/get_android_assetlinks.go
Normal file
23
internal/api/handlers/wellknown/get_android_assetlinks.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package wellknown
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetAndroidDigitalAssetLinksRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.WellKnown.GET("/assetlinks.json", getAndroidDigitalAssetLinksHandler(s))
|
||||
}
|
||||
|
||||
func getAndroidDigitalAssetLinksHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if s.Config.Paths.AndroidAssetlinksFile == "" {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
|
||||
c.Response().Header().Set("Content-Type", "application/json")
|
||||
|
||||
return c.File(s.Config.Paths.AndroidAssetlinksFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package wellknown_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func TestGetAndroidWellKnown(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Paths.AndroidAssetlinksFile = filepath.Join(util.GetProjectRootDir(), "test", "testdata", "android-assetlinks.json")
|
||||
|
||||
testGetWellKnown(t, config, "/.well-known/assetlinks.json")
|
||||
}
|
||||
|
||||
func TestGetAndroidWellKnownNotFound(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Paths.AndroidAssetlinksFile = ""
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/.well-known/assetlinks.json", nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrNotFound))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package wellknown
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func GetAppleAppSiteAssociationRoute(s *api.Server) *echo.Route {
|
||||
return s.Router.WellKnown.GET("/apple-app-site-association", getAppleAppSiteAssociationHandler(s))
|
||||
}
|
||||
|
||||
func getAppleAppSiteAssociationHandler(s *api.Server) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if s.Config.Paths.AppleAppSiteAssociationFile == "" {
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
|
||||
return c.File(s.Config.Paths.AppleAppSiteAssociationFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package wellknown_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testGetWellKnown(t *testing.T, config config.Server, path string) {
|
||||
t.Helper()
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", path, nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
result, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.Snapshoter.SaveString(t, string(result))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAppleWellKnown(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Paths.AppleAppSiteAssociationFile = filepath.Join(util.GetProjectRootDir(), "test", "testdata", "apple-app-site-association.json")
|
||||
|
||||
testGetWellKnown(t, config, "/.well-known/apple-app-site-association")
|
||||
}
|
||||
|
||||
func TestGetAppleWellKnownNotFound(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Paths.AppleAppSiteAssociationFile = ""
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/.well-known/apple-app-site-association", nil, nil)
|
||||
test.RequireHTTPError(t, res, httperrors.NewFromEcho(echo.ErrNotFound))
|
||||
})
|
||||
}
|
||||
16
internal/api/httperrors/auth.go
Normal file
16
internal/api/httperrors/auth.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package httperrors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrForbiddenUserDeactivated = NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeUSERDEACTIVATED, "User account is deactivated")
|
||||
ErrBadRequestInvalidPassword = NewHTTPErrorWithDetail(http.StatusBadRequest, types.PublicHTTPErrorTypeINVALIDPASSWORD, "The password provided was invalid", "Password was either too weak or did not match other criteria")
|
||||
ErrForbiddenNotLocalUser = NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeNOTLOCALUSER, "User account is not valid for local authentication")
|
||||
ErrNotFoundTokenNotFound = NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeTOKENNOTFOUND, "Provided token was not found")
|
||||
ErrConflictTokenExpired = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypeTOKENEXPIRED, "Provided token has expired and is no longer valid")
|
||||
ErrConflictUserAlreadyExists = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypeUSERALREADYEXISTS, "User with given username already exists")
|
||||
)
|
||||
11
internal/api/httperrors/common.go
Normal file
11
internal/api/httperrors/common.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package httperrors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadRequestZeroFileSize = NewHTTPError(http.StatusBadRequest, types.PublicHTTPErrorTypeZEROFILESIZE, "File size of 0 is not supported.")
|
||||
)
|
||||
147
internal/api/httperrors/error.go
Normal file
147
internal/api/httperrors/error.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package httperrors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Payload in accordance with RFC 7807 (Problem Details for HTTP APIs) with the exception of the type
|
||||
// value not being represented by a URI. https://tools.ietf.org/html/rfc7807 @ 2020-04-27T15:44:37Z
|
||||
|
||||
type HTTPError struct {
|
||||
types.PublicHTTPError
|
||||
Internal error `json:"-"`
|
||||
AdditionalData map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
type HTTPValidationError struct {
|
||||
types.PublicHTTPValidationError
|
||||
Internal error `json:"-"`
|
||||
AdditionalData map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
func NewHTTPError(code int, errorType types.PublicHTTPErrorType, title string) *HTTPError {
|
||||
return &HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(code)),
|
||||
Type: errorType.Pointer(),
|
||||
Title: swag.String(title),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewHTTPErrorWithDetail(code int, errorType types.PublicHTTPErrorType, title string, detail string) *HTTPError {
|
||||
return &HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(code)),
|
||||
Type: errorType.Pointer(),
|
||||
Title: swag.String(title),
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewFromEcho(e *echo.HTTPError) *HTTPError {
|
||||
return NewHTTPError(e.Code, types.PublicHTTPErrorTypeGeneric, http.StatusText(e.Code))
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
var builder strings.Builder
|
||||
|
||||
fmt.Fprintf(&builder, "HTTPError %d (%s): %s", *e.Code, *e.Type, *e.Title)
|
||||
|
||||
if len(e.Detail) > 0 {
|
||||
fmt.Fprintf(&builder, " - %s", e.Detail)
|
||||
}
|
||||
if e.Internal != nil {
|
||||
fmt.Fprintf(&builder, ", %v", e.Internal)
|
||||
}
|
||||
if len(e.AdditionalData) > 0 {
|
||||
keys := make([]string, 0, len(e.AdditionalData))
|
||||
for k := range e.AdditionalData {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
builder.WriteString(". Additional: ")
|
||||
for i, k := range keys {
|
||||
fmt.Fprintf(&builder, "%s=%v", k, e.AdditionalData[k])
|
||||
if i < len(keys)-1 {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func NewHTTPValidationError(code int, errorType types.PublicHTTPErrorType, title string, validationErrors []*types.HTTPValidationErrorDetail) *HTTPValidationError {
|
||||
return &HTTPValidationError{
|
||||
PublicHTTPValidationError: types.PublicHTTPValidationError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(code)),
|
||||
Type: errorType.Pointer(),
|
||||
Title: swag.String(title),
|
||||
},
|
||||
ValidationErrors: validationErrors,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewHTTPValidationErrorWithDetail(code int, errorType types.PublicHTTPErrorType, title string, validationErrors []*types.HTTPValidationErrorDetail, detail string) *HTTPValidationError {
|
||||
return &HTTPValidationError{
|
||||
PublicHTTPValidationError: types.PublicHTTPValidationError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(code)),
|
||||
Type: errorType.Pointer(),
|
||||
Title: swag.String(title),
|
||||
Detail: detail,
|
||||
},
|
||||
ValidationErrors: validationErrors,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HTTPValidationError) Error() string {
|
||||
var builder strings.Builder
|
||||
|
||||
fmt.Fprintf(&builder, "HTTPValidationError %d (%s): %s", *e.Code, *e.Type, *e.Title)
|
||||
|
||||
if len(e.Detail) > 0 {
|
||||
fmt.Fprintf(&builder, " - %s", e.Detail)
|
||||
}
|
||||
if e.Internal != nil {
|
||||
fmt.Fprintf(&builder, ", %v", e.Internal)
|
||||
}
|
||||
if len(e.AdditionalData) > 0 {
|
||||
keys := make([]string, 0, len(e.AdditionalData))
|
||||
for k := range e.AdditionalData {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
builder.WriteString(". Additional: ")
|
||||
for i, k := range keys {
|
||||
fmt.Fprintf(&builder, "%s=%v", k, e.AdditionalData[k])
|
||||
if i < len(keys)-1 {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder.WriteString(" - Validation: ")
|
||||
for i, ve := range e.ValidationErrors {
|
||||
fmt.Fprintf(&builder, "%s (in %s): %s", *ve.Key, *ve.In, *ve.Error)
|
||||
if i < len(e.ValidationErrors)-1 {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
80
internal/api/httperrors/error_test.go
Normal file
80
internal/api/httperrors/error_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package httperrors_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHTTPErrorSimple(t *testing.T) {
|
||||
err := httperrors.NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusNotFound))
|
||||
require.Equal(t, "HTTPError 404 (generic): Not Found", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPErrorDetail(t *testing.T) {
|
||||
err := httperrors.NewHTTPErrorWithDetail(http.StatusNotFound, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusNotFound), "ToS violation")
|
||||
require.Equal(t, "HTTPError 404 (generic): Not Found - ToS violation", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPErrorInternalError(t *testing.T) {
|
||||
err := httperrors.NewHTTPError(http.StatusInternalServerError, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusInternalServerError))
|
||||
|
||||
err.Internal = sql.ErrConnDone
|
||||
|
||||
require.Equal(t, "HTTPError 500 (generic): Internal Server Error, sql: connection is already closed", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPErrorAdditionalData(t *testing.T) {
|
||||
err := httperrors.NewHTTPError(http.StatusInternalServerError, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusInternalServerError))
|
||||
|
||||
err.AdditionalData = map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
}
|
||||
|
||||
require.Equal(t, "HTTPError 500 (generic): Internal Server Error. Additional: key1=value1, key2=value2", err.Error())
|
||||
}
|
||||
|
||||
var valErrs = append(make([]*types.HTTPValidationErrorDetail, 0, 2), &types.HTTPValidationErrorDetail{
|
||||
Key: swag.String("test1"),
|
||||
In: swag.String("body.test1"),
|
||||
Error: swag.String("ValidationError"),
|
||||
}, &types.HTTPValidationErrorDetail{
|
||||
Key: swag.String("test2"),
|
||||
In: swag.String("body.test2"),
|
||||
Error: swag.String("Validation Error"),
|
||||
})
|
||||
|
||||
func TestHTTPValidationErrorSimple(t *testing.T) {
|
||||
err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
|
||||
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPValidationErrorDetail(t *testing.T) {
|
||||
err := httperrors.NewHTTPValidationErrorWithDetail(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs, "Did API spec change?")
|
||||
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request - Did API spec change? - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPValidationErrorInternalError(t *testing.T) {
|
||||
err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
|
||||
|
||||
err.Internal = sql.ErrConnDone
|
||||
|
||||
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request, sql: connection is already closed - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
|
||||
}
|
||||
|
||||
func TestHTTPValidationErrorAdditionalData(t *testing.T) {
|
||||
err := httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
|
||||
|
||||
err.AdditionalData = map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
}
|
||||
|
||||
require.Equal(t, "HTTPValidationError 400 (generic): Bad Request. Additional: key1=value1, key2=value2 - Validation: test1 (in body.test1): ValidationError, test2 (in body.test2): Validation Error", err.Error())
|
||||
}
|
||||
12
internal/api/httperrors/push.go
Normal file
12
internal/api/httperrors/push.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package httperrors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConflictPushToken = NewHTTPError(http.StatusConflict, types.PublicHTTPErrorTypePUSHTOKENALREADYEXISTS, "The given token already exists.")
|
||||
ErrNotFoundOldPushToken = NewHTTPError(http.StatusNotFound, types.PublicHTTPErrorTypeOLDPUSHTOKENNOTFOUND, "The old push token does not exists. The new token was saved.")
|
||||
)
|
||||
390
internal/api/middleware/auth.go
Normal file
390
internal/api/middleware/auth.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/mapper"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadRequestMalformedToken = httperrors.NewHTTPError(http.StatusBadRequest, types.PublicHTTPErrorTypeMALFORMEDTOKEN, "Auth token is malformed")
|
||||
ErrUnauthorizedLastAuthenticatedAtExceeded = httperrors.NewHTTPError(http.StatusUnauthorized, types.PublicHTTPErrorTypeLASTAUTHENTICATEDATEXCEEDED, "LastAuthenticatedAt timestamp exceeds threshold, re-authentication required")
|
||||
ErrForbiddenMissingScopes = httperrors.NewHTTPError(http.StatusForbidden, types.PublicHTTPErrorTypeMISSINGSCOPES, "User is missing required scopes")
|
||||
ErrAuthTokenValidationFailed = errors.New("auth token validation failed")
|
||||
)
|
||||
|
||||
// AuthMode controls the type of authentication check performed for a specific route or group
|
||||
type AuthMode int
|
||||
|
||||
const (
|
||||
// AuthModeRequired requires an auth token to be present and valid in order to access the route or group
|
||||
AuthModeRequired AuthMode = iota
|
||||
// AuthModeSecure requires an auth token to be present and for the user to have recently re-confirmed their authentication in order to access the route or group
|
||||
AuthModeSecure
|
||||
// AuthModeOptional does not require an auth token to be present, however if it is, it must be valid in order to access the route or group
|
||||
AuthModeOptional
|
||||
// AuthModeTry does not require an auth token to be present in order to access the route or group and will process the request even if an invalid one has been provided
|
||||
AuthModeTry
|
||||
// AuthModeNone does not require an auth token to be present in order to access the route or group and will not attempt to parse any authentication provided
|
||||
AuthModeNone
|
||||
)
|
||||
|
||||
func (m AuthMode) String() string {
|
||||
switch m {
|
||||
case AuthModeRequired:
|
||||
return "required"
|
||||
case AuthModeSecure:
|
||||
return "secure"
|
||||
case AuthModeOptional:
|
||||
return "optional"
|
||||
case AuthModeTry:
|
||||
return "try"
|
||||
case AuthModeNone:
|
||||
return "none"
|
||||
default:
|
||||
return fmt.Sprintf("unknown (%d)", m)
|
||||
}
|
||||
}
|
||||
|
||||
type AuthFailureMode int
|
||||
|
||||
const (
|
||||
// AuthFailureModeUnauthorized returns a 401 Unauthorized response on missing or invalid authentication
|
||||
AuthFailureModeUnauthorized AuthFailureMode = iota
|
||||
// AuthFailureModeNotFound returns a 404 Not Found response on missing or invalid authentication
|
||||
AuthFailureModeNotFound
|
||||
)
|
||||
|
||||
func (m AuthFailureMode) String() string {
|
||||
switch m {
|
||||
case AuthFailureModeUnauthorized:
|
||||
return "unauthorized"
|
||||
case AuthFailureModeNotFound:
|
||||
return "not_found"
|
||||
default:
|
||||
return fmt.Sprintf("unknown (%d)", m)
|
||||
}
|
||||
}
|
||||
|
||||
func (m AuthFailureMode) Error() error {
|
||||
switch m {
|
||||
case AuthFailureModeUnauthorized:
|
||||
return echo.ErrUnauthorized
|
||||
case AuthFailureModeNotFound:
|
||||
return echo.ErrNotFound
|
||||
default:
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
type AuthTokenSource int
|
||||
|
||||
const (
|
||||
// AuthTokenSourceHeader retrieves the auth token from a header, specified by TokenSourceKey
|
||||
AuthTokenSourceHeader AuthTokenSource = iota
|
||||
// AuthTokenSourceQuery retrieves the auth token from a query parameter, specified by TokenSourceKey
|
||||
AuthTokenSourceQuery
|
||||
// AuthTokenSourceForm retrieves the auth token from a form parameter, specified by TokenSourceKey
|
||||
AuthTokenSourceForm
|
||||
)
|
||||
|
||||
func (s AuthTokenSource) String() string {
|
||||
switch s {
|
||||
case AuthTokenSourceHeader:
|
||||
return "header"
|
||||
case AuthTokenSourceQuery:
|
||||
return "query"
|
||||
case AuthTokenSourceForm:
|
||||
return "form"
|
||||
default:
|
||||
return fmt.Sprintf("unknown (%d)", s)
|
||||
}
|
||||
}
|
||||
|
||||
func (s AuthTokenSource) Extract(c echo.Context, key string, scheme string) (string, bool) {
|
||||
var token string
|
||||
|
||||
switch s {
|
||||
case AuthTokenSourceHeader:
|
||||
token = c.Request().Header.Get(key)
|
||||
case AuthTokenSourceForm:
|
||||
token = c.FormValue(key)
|
||||
case AuthTokenSourceQuery:
|
||||
token = c.QueryParam(key)
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
||||
if len(token) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
lenScheme := len(scheme)
|
||||
if lenScheme == 0 {
|
||||
return token, true
|
||||
}
|
||||
|
||||
if len(token) < lenScheme+1 {
|
||||
return "", true
|
||||
}
|
||||
|
||||
if token[:lenScheme] != scheme {
|
||||
return "", true
|
||||
}
|
||||
|
||||
return token[lenScheme+1:], true
|
||||
}
|
||||
|
||||
type AuthTokenFormatValidator func(string) bool
|
||||
|
||||
func DefaultAuthTokenFormatValidator(token string) bool {
|
||||
return strfmt.IsUUID4(token)
|
||||
}
|
||||
|
||||
type AuthTokenValidator func(c echo.Context, config AuthConfig, token string) (auth.Result, error)
|
||||
|
||||
func DefaultAuthTokenValidator(c echo.Context, config AuthConfig, token string) (auth.Result, error) {
|
||||
accessToken, err := models.AccessTokens(
|
||||
models.AccessTokenWhere.Token.EQ(token),
|
||||
qm.Load(qm.Rels(models.AccessTokenRels.User, models.UserRels.AppUserProfile)),
|
||||
).One(c.Request().Context(), config.S.DB)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Trace().Err(err).Msg("Access token not found in database")
|
||||
return auth.Result{}, ErrAuthTokenValidationFailed
|
||||
}
|
||||
|
||||
log.Error().Err(err).Msg("Failed to query for access token in database, aborting request")
|
||||
return auth.Result{}, echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
return auth.Result{
|
||||
Token: accessToken.Token,
|
||||
User: mapper.LocalUserToDTO(accessToken.R.User).Ptr(),
|
||||
ValidUntil: accessToken.ValidUntil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultAuthConfig = AuthConfig{
|
||||
Mode: AuthModeRequired,
|
||||
FailureMode: AuthFailureModeUnauthorized,
|
||||
TokenSource: AuthTokenSourceHeader,
|
||||
TokenSourceKey: echo.HeaderAuthorization,
|
||||
Scheme: "Bearer",
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
FormatValidator: DefaultAuthTokenFormatValidator,
|
||||
TokenValidator: DefaultAuthTokenValidator,
|
||||
Scopes: []string{auth.ScopeApp.String()},
|
||||
}
|
||||
)
|
||||
|
||||
type AuthConfig struct {
|
||||
S *api.Server // API server used for database and service access
|
||||
Mode AuthMode // Controls type of authentication required (default: AuthModeRequired)
|
||||
FailureMode AuthFailureMode // Controls response on auth failure (default: AuthFailureModeUnauthorized)
|
||||
TokenSource AuthTokenSource // Sets source of auth token (default: AuthTokenSourceHeader)
|
||||
TokenSourceKey string // Sets key for auth token source lookup (default: "Authorization")
|
||||
Scheme string // Sets required token scheme (default: "Bearer")
|
||||
Skipper middleware.Skipper // Controls skipping of certain routes (default: no skipped routes)
|
||||
FormatValidator AuthTokenFormatValidator // Validates the format of the token retrieved
|
||||
TokenValidator AuthTokenValidator // Validates token retrieved and returns associated user (default: performs lookup in access_tokens table)
|
||||
Scopes []string // List of scopes required to access endpoint (default: none required)
|
||||
}
|
||||
|
||||
func (c AuthConfig) CheckLastAuthenticatedAt(user *dto.User) bool {
|
||||
if c.Mode != AuthModeSecure {
|
||||
return true
|
||||
}
|
||||
|
||||
if !user.LastAuthenticatedAt.Valid {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Since(user.LastAuthenticatedAt.Time).Seconds() <= c.S.Config.Auth.LastAuthenticatedAtThreshold.Seconds()
|
||||
}
|
||||
|
||||
func (c AuthConfig) CheckUserScopes(user *dto.User) bool {
|
||||
if len(c.Scopes) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(user.Scopes) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, scope := range c.Scopes {
|
||||
for _, userScope := range user.Scopes {
|
||||
if scope == userScope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func Auth(s *api.Server) echo.MiddlewareFunc {
|
||||
c := DefaultAuthConfig
|
||||
c.S = s
|
||||
return AuthWithConfig(c)
|
||||
}
|
||||
|
||||
func AuthWithConfig(config AuthConfig) echo.MiddlewareFunc {
|
||||
if config.S == nil {
|
||||
panic("auth middleware: server is required")
|
||||
}
|
||||
|
||||
if len(config.TokenSourceKey) == 0 {
|
||||
config.TokenSourceKey = DefaultAuthConfig.TokenSourceKey
|
||||
}
|
||||
|
||||
if len(config.Scheme) == 0 {
|
||||
config.Scheme = DefaultAuthConfig.Scheme
|
||||
}
|
||||
|
||||
if config.Skipper == nil {
|
||||
config.Skipper = DefaultAuthConfig.Skipper
|
||||
}
|
||||
|
||||
if config.FormatValidator == nil {
|
||||
config.FormatValidator = DefaultAuthConfig.FormatValidator
|
||||
}
|
||||
|
||||
if config.TokenValidator == nil {
|
||||
config.TokenValidator = DefaultAuthConfig.TokenValidator
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
log := util.LogFromEchoContext(c).With().Str("middleware", "auth").Str("auth_mode", config.Mode.String()).Logger()
|
||||
|
||||
if config.Mode == AuthModeNone {
|
||||
log.Trace().Msg("No authentication required, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if config.Skipper(c) {
|
||||
log.Trace().Msg("Skipping auth middleware, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
user := auth.UserFromEchoContext(c)
|
||||
if user != nil {
|
||||
if !config.CheckLastAuthenticatedAt(user) {
|
||||
log.Trace().
|
||||
Time("last_authenticated_at", user.LastAuthenticatedAt.Time).
|
||||
Dur("last_authenticated_at_threshold", config.S.Config.Auth.LastAuthenticatedAtThreshold).
|
||||
Msg("Authentication already performed, but last authenticated at time exceeds threshold, rejecting request")
|
||||
return ErrUnauthorizedLastAuthenticatedAtExceeded
|
||||
}
|
||||
|
||||
if !config.CheckUserScopes(user) {
|
||||
log.Trace().
|
||||
Strs("scopes", config.Scopes).
|
||||
Strs("user_scopes", user.Scopes).
|
||||
Msg("Authentication already performed, but user does not have required scopes, rejecting request")
|
||||
return ErrForbiddenMissingScopes
|
||||
}
|
||||
|
||||
log.Trace().Msg("Authentication already performed, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
token, exists := config.TokenSource.Extract(c, config.TokenSourceKey, config.Scheme)
|
||||
if len(token) == 0 {
|
||||
if config.Mode == AuthModeRequired || config.Mode == AuthModeSecure || (exists && config.Mode == AuthModeOptional) {
|
||||
log.Trace().Bool("token_exists", exists).Msg("Request has missing or malformed token, rejecting")
|
||||
return config.FailureMode.Error()
|
||||
}
|
||||
|
||||
log.Trace().Bool("token_exists", exists).Msg("Request does not have valid token, but auth mode permits access, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if !config.FormatValidator(token) {
|
||||
if config.Mode == AuthModeRequired || config.Mode == AuthModeSecure || config.Mode == AuthModeOptional {
|
||||
log.Trace().Msg("Request has malformed token, rejecting")
|
||||
return ErrBadRequestMalformedToken
|
||||
}
|
||||
|
||||
log.Trace().Msg("Request have malformed token, but auth mode permits access, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
res, err := config.TokenValidator(c, config, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuthTokenValidationFailed) {
|
||||
if config.Mode == AuthModeTry {
|
||||
log.Trace().Msg("Auth token validation failed, but auth mode permits access, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
log.Trace().Msg("Auth token validation failed, rejecting request")
|
||||
return config.FailureMode.Error()
|
||||
}
|
||||
|
||||
log.Trace().Err(err).Msg("Failed to validate auth token, aborting request")
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
user = res.User
|
||||
|
||||
if res.ValidUntil.IsZero() {
|
||||
log.Trace().Str("user_id", user.ID).Msg("Auth token has no expiry, allowing request")
|
||||
} else if config.S.Clock.Now().After(res.ValidUntil) {
|
||||
if config.Mode == AuthModeTry {
|
||||
log.Trace().Time("valid_until", res.ValidUntil).Str("user_id", user.ID).Msg("Auth token is expired, but auth mode permits access, allowing request")
|
||||
return next(c)
|
||||
}
|
||||
|
||||
log.Trace().Time("valid_until", res.ValidUntil).Str("user_id", user.ID).Msg("Auth token is expired, rejecting request")
|
||||
return config.FailureMode.Error()
|
||||
}
|
||||
|
||||
// ! User has been explicitly deactivated - we do not allow access here, even with AuthModeTry
|
||||
if !user.IsActive {
|
||||
log.Trace().Str("user_id", user.ID).Msg("User is deactivated, rejecting request")
|
||||
return httperrors.ErrForbiddenUserDeactivated
|
||||
}
|
||||
|
||||
if !config.CheckLastAuthenticatedAt(user) {
|
||||
log.Trace().
|
||||
Time("last_authenticated_at", user.LastAuthenticatedAt.Time).
|
||||
Dur("last_authenticated_at_threshold", config.S.Config.Auth.LastAuthenticatedAtThreshold).
|
||||
Msg("Authentication already performed, but last authenticated at time exceeds threshold, rejecting request")
|
||||
return ErrUnauthorizedLastAuthenticatedAtExceeded
|
||||
}
|
||||
|
||||
if !config.CheckUserScopes(user) {
|
||||
log.Trace().
|
||||
Strs("scopes", config.Scopes).
|
||||
Strs("user_scopes", user.Scopes).
|
||||
Msg("Authentication already performed, but user does not have required scopes, rejecting request")
|
||||
return ErrForbiddenMissingScopes
|
||||
}
|
||||
|
||||
auth.EnrichEchoContextWithCredentials(c, res)
|
||||
|
||||
log.Trace().Str("user_id", user.ID).Msg("Auth token is valid, allowing request")
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
55
internal/api/middleware/cache_control.go
Normal file
55
internal/api/middleware/cache_control.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultCacheControlConfig = CacheControlConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
}
|
||||
)
|
||||
|
||||
type CacheControlConfig struct {
|
||||
Skipper middleware.Skipper
|
||||
}
|
||||
|
||||
func CacheControl() echo.MiddlewareFunc {
|
||||
return CacheControlWithConfig(DefaultCacheControlConfig)
|
||||
}
|
||||
|
||||
func CacheControlWithConfig(config CacheControlConfig) echo.MiddlewareFunc {
|
||||
if config.Skipper == nil {
|
||||
config.Skipper = DefaultCacheControlConfig.Skipper
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if config.Skipper(c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
cacheControl := c.Request().Header.Get(util.HTTPHeaderCacheControl)
|
||||
if len(cacheControl) > 0 {
|
||||
directive := util.ParseCacheControlHeader(cacheControl)
|
||||
|
||||
ctx := c.Request().Context()
|
||||
|
||||
l := util.LogFromContext(ctx).With().Str("cacheControl", directive.String()).Logger()
|
||||
ctx = l.WithContext(ctx)
|
||||
|
||||
ctx = context.WithValue(ctx, util.CTXKeyCacheControl, directive)
|
||||
|
||||
l.Trace().Msg("Setting cache control directive for request")
|
||||
|
||||
c.SetRequest(c.Request().WithContext(ctx))
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
334
internal/api/middleware/logger.go
Normal file
334
internal/api/middleware/logger.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// RequestBodyLogSkipper defines a function to skip logging certain request bodies.
|
||||
// Returning true skips logging the payload of the request.
|
||||
type RequestBodyLogSkipper func(req *http.Request) bool
|
||||
|
||||
// DefaultRequestBodyLogSkipper returns true for all requests with Content-Type
|
||||
// application/x-www-form-urlencoded or multipart/form-data as those might contain
|
||||
// binary or URL-encoded file uploads unfit for logging purposes.
|
||||
func DefaultRequestBodyLogSkipper(req *http.Request) bool {
|
||||
contentType := req.Header.Get(echo.HeaderContentType)
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, echo.MIMEApplicationForm),
|
||||
strings.HasPrefix(contentType, echo.MIMEMultipartForm):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ResponseBodyLogSkipper defines a function to skip logging certain response bodies.
|
||||
// Returning true skips logging the payload of the response.
|
||||
type ResponseBodyLogSkipper func(req *http.Request, res *echo.Response) bool
|
||||
|
||||
// DefaultResponseBodyLogSkipper returns false for all responses with Content-Type
|
||||
// application/json, preventing logging for all other types of payloads as those
|
||||
// might contain binary or URL-encoded data unfit for logging purposes.
|
||||
func DefaultResponseBodyLogSkipper(_ *http.Request, res *echo.Response) bool {
|
||||
contentType := res.Header().Get(echo.HeaderContentType)
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, echo.MIMEApplicationJSON):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// BodyLogReplacer defines a function to replace certain parts of a body before logging it,
|
||||
// mainly used to strip sensitive information from a request or response payload.
|
||||
// The []byte returned should contain a sanitized payload ready for logging.
|
||||
type BodyLogReplacer func(body []byte) []byte
|
||||
|
||||
// DefaultBodyLogReplacer returns the body received without any modifications.
|
||||
func DefaultBodyLogReplacer(body []byte) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
// HeaderLogReplacer defines a function to replace certain parts of a header before logging it,
|
||||
// mainly used to strip sensitive information from a request or response header.
|
||||
// The http.Header returned should be a sanitized copy of the original header as not to modify
|
||||
// the request or response while logging.
|
||||
type HeaderLogReplacer func(header http.Header) http.Header
|
||||
|
||||
// DefaultHeaderLogReplacer replaces all Authorization, X-CSRF-Token and Proxy-Authorization
|
||||
// header entries with a redacted string, indicating their presence without revealing actual,
|
||||
// potentially sensitive values in the logs.
|
||||
func DefaultHeaderLogReplacer(headers http.Header) http.Header {
|
||||
sanitizedHeader := http.Header{}
|
||||
|
||||
for key, value := range headers {
|
||||
shouldRedact := strings.EqualFold(key, echo.HeaderAuthorization) ||
|
||||
strings.EqualFold(key, echo.HeaderXCSRFToken) ||
|
||||
strings.EqualFold(key, "Proxy-Authorization")
|
||||
|
||||
for _, v := range value {
|
||||
if shouldRedact {
|
||||
sanitizedHeader.Add(key, "*****REDACTED*****")
|
||||
} else {
|
||||
sanitizedHeader.Add(key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizedHeader
|
||||
}
|
||||
|
||||
// QueryLogReplacer defines a function to replace certain parts of a URL query before logging it,
|
||||
// mainly used to strip sensitive information from a request query.
|
||||
// The url.Values returned should be a sanitized copy of the original query as not to modify the
|
||||
// request while logging.
|
||||
type QueryLogReplacer func(query url.Values) url.Values
|
||||
|
||||
// DefaultQueryLogReplacer returns the query received without any modifications.
|
||||
func DefaultQueryLogReplacer(query url.Values) url.Values {
|
||||
return query
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultLoggerConfig = LoggerConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
Level: zerolog.DebugLevel,
|
||||
LogRequestBody: false,
|
||||
LogRequestHeader: false,
|
||||
LogRequestQuery: false,
|
||||
RequestBodyLogSkipper: DefaultRequestBodyLogSkipper,
|
||||
RequestBodyLogReplacer: DefaultBodyLogReplacer,
|
||||
RequestHeaderLogReplacer: DefaultHeaderLogReplacer,
|
||||
RequestQueryLogReplacer: DefaultQueryLogReplacer,
|
||||
LogResponseBody: false,
|
||||
LogResponseHeader: false,
|
||||
ResponseBodyLogSkipper: DefaultResponseBodyLogSkipper,
|
||||
ResponseBodyLogReplacer: DefaultBodyLogReplacer,
|
||||
}
|
||||
)
|
||||
|
||||
type LoggerConfig struct {
|
||||
Skipper middleware.Skipper
|
||||
Level zerolog.Level
|
||||
LogRequestBody bool
|
||||
LogRequestHeader bool
|
||||
LogRequestQuery bool
|
||||
LogCaller bool
|
||||
RequestBodyLogSkipper RequestBodyLogSkipper
|
||||
RequestBodyLogReplacer BodyLogReplacer
|
||||
RequestHeaderLogReplacer HeaderLogReplacer
|
||||
RequestQueryLogReplacer QueryLogReplacer
|
||||
LogResponseBody bool
|
||||
LogResponseHeader bool
|
||||
ResponseBodyLogSkipper ResponseBodyLogSkipper
|
||||
ResponseBodyLogReplacer BodyLogReplacer
|
||||
ResponseHeaderLogReplacer HeaderLogReplacer
|
||||
}
|
||||
|
||||
// Logger with default logger output and configuration
|
||||
func Logger() echo.MiddlewareFunc {
|
||||
return LoggerWithConfig(DefaultLoggerConfig, nil)
|
||||
}
|
||||
|
||||
// LoggerWithConfig returns a new MiddlewareFunc which creates a logger with the desired configuration.
|
||||
// If output is set to nil, the default output is used. If more output params are provided, the first is being used.
|
||||
func LoggerWithConfig(config LoggerConfig, output ...io.Writer) echo.MiddlewareFunc {
|
||||
if config.Skipper == nil {
|
||||
config.Skipper = DefaultLoggerConfig.Skipper
|
||||
}
|
||||
if config.RequestBodyLogSkipper == nil {
|
||||
config.RequestBodyLogSkipper = DefaultRequestBodyLogSkipper
|
||||
}
|
||||
if config.RequestBodyLogReplacer == nil {
|
||||
config.RequestBodyLogReplacer = DefaultBodyLogReplacer
|
||||
}
|
||||
if config.RequestHeaderLogReplacer == nil {
|
||||
config.RequestHeaderLogReplacer = DefaultHeaderLogReplacer
|
||||
}
|
||||
if config.RequestQueryLogReplacer == nil {
|
||||
config.RequestQueryLogReplacer = DefaultQueryLogReplacer
|
||||
}
|
||||
if config.ResponseBodyLogSkipper == nil {
|
||||
config.ResponseBodyLogSkipper = DefaultResponseBodyLogSkipper
|
||||
}
|
||||
if config.ResponseBodyLogReplacer == nil {
|
||||
config.ResponseBodyLogReplacer = DefaultBodyLogReplacer
|
||||
}
|
||||
if config.ResponseHeaderLogReplacer == nil {
|
||||
config.ResponseHeaderLogReplacer = DefaultHeaderLogReplacer
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if config.Skipper(c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
req := c.Request()
|
||||
res := c.Response()
|
||||
|
||||
requestID := req.Header.Get(echo.HeaderXRequestID)
|
||||
if len(requestID) == 0 {
|
||||
requestID = res.Header().Get(echo.HeaderXRequestID)
|
||||
}
|
||||
|
||||
contentLength := req.Header.Get(echo.HeaderContentLength)
|
||||
if len(contentLength) == 0 {
|
||||
contentLength = "0"
|
||||
}
|
||||
|
||||
logger := log.With().
|
||||
Dict("req", zerolog.Dict().
|
||||
Str("id", requestID).
|
||||
Str("host", req.Host).
|
||||
Str("method", req.Method).
|
||||
Str("url", req.URL.String()).
|
||||
Str("bytes_in", contentLength),
|
||||
).Logger()
|
||||
|
||||
if len(output) > 0 {
|
||||
logger = logger.Output(output[0])
|
||||
}
|
||||
|
||||
if config.LogCaller {
|
||||
// Caller uses https://pkg.go.dev/runtime#Caller underneath and might decrease the performance.
|
||||
logger = logger.With().Caller().Logger()
|
||||
}
|
||||
|
||||
le := logger.WithLevel(config.Level)
|
||||
req = req.WithContext(logger.WithContext(context.WithValue(req.Context(), util.CTXKeyRequestID, requestID)))
|
||||
|
||||
if config.LogRequestBody && !config.RequestBodyLogSkipper(req) {
|
||||
var reqBody []byte
|
||||
var err error
|
||||
if req.Body != nil {
|
||||
reqBody, err = io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to read body while logging request")
|
||||
return fmt.Errorf("failed to read body while logging request: %w", err)
|
||||
}
|
||||
|
||||
req.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||
}
|
||||
|
||||
le = le.Bytes("req_body", config.RequestBodyLogReplacer(reqBody))
|
||||
}
|
||||
if config.LogRequestHeader {
|
||||
header := zerolog.Dict()
|
||||
for k, v := range config.RequestHeaderLogReplacer(req.Header) {
|
||||
header.Strs(k, v)
|
||||
}
|
||||
|
||||
le = le.Dict("req_header", header)
|
||||
}
|
||||
if config.LogRequestQuery {
|
||||
query := zerolog.Dict()
|
||||
for k, v := range req.URL.Query() {
|
||||
query.Strs(k, v)
|
||||
}
|
||||
|
||||
le = le.Dict("req_query", query)
|
||||
}
|
||||
|
||||
le.Msg("Request received")
|
||||
|
||||
c.SetRequest(req)
|
||||
|
||||
var resBody bytes.Buffer
|
||||
if config.LogResponseBody {
|
||||
mw := io.MultiWriter(res.Writer, &resBody)
|
||||
writer := &bodyDumpResponseWriter{Writer: mw, ResponseWriter: res.Writer}
|
||||
res.Writer = writer
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
err := next(c)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
}
|
||||
stop := time.Now()
|
||||
|
||||
// Retrieve logger from context again since other middlewares might have enhanced it
|
||||
ll := util.LogFromEchoContext(c)
|
||||
lle := ll.WithLevel(config.Level).
|
||||
Dict("res", zerolog.Dict().
|
||||
Int("status", res.Status).
|
||||
Int64("bytes_out", res.Size).
|
||||
TimeDiff("duration_ms", stop, start).
|
||||
Err(err),
|
||||
)
|
||||
|
||||
if config.LogResponseBody && !config.ResponseBodyLogSkipper(req, res) {
|
||||
lle = lle.Bytes("res_body", config.ResponseBodyLogReplacer(resBody.Bytes()))
|
||||
}
|
||||
if config.LogResponseHeader {
|
||||
header := zerolog.Dict()
|
||||
for k, v := range config.ResponseHeaderLogReplacer(res.Header()) {
|
||||
header.Strs(k, v)
|
||||
}
|
||||
|
||||
lle = lle.Dict("res_header", header)
|
||||
}
|
||||
|
||||
lle.Msg("Response sent")
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type bodyDumpResponseWriter struct {
|
||||
io.Writer
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
func (w *bodyDumpResponseWriter) WriteHeader(code int) {
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *bodyDumpResponseWriter) Write(b []byte) (int, error) {
|
||||
n, err := w.Writer.Write(b)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write response body: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *bodyDumpResponseWriter) Flush() {
|
||||
flusher, ok := w.ResponseWriter.(http.Flusher)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("failed to get flusher as http.Flusher, got %T", w.ResponseWriter))
|
||||
}
|
||||
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
func (w *bodyDumpResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hijacker, ok := w.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("failed to get hijacker as http.Hijacker, got %T", w.ResponseWriter)
|
||||
}
|
||||
|
||||
conn, rw, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to hijack connection: %w", err)
|
||||
}
|
||||
|
||||
return conn, rw, nil
|
||||
}
|
||||
57
internal/api/middleware/logger_test.go
Normal file
57
internal/api/middleware/logger_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func logTestHandler(c echo.Context) error {
|
||||
log := util.LogFromEchoContext(c)
|
||||
log.Info().Msg("I'm here!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLogWithCaller(t *testing.T) {
|
||||
cfg := middleware.DefaultLoggerConfig
|
||||
cfg.LogCaller = false
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
loggerMW := middleware.LoggerWithConfig(cfg, rec)
|
||||
|
||||
e := echo.New()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
middlewareChain := loggerMW(logTestHandler)
|
||||
require.NoError(t, middlewareChain(c))
|
||||
|
||||
bufSize := 2000
|
||||
loggedData := make([]byte, bufSize)
|
||||
n, err := rec.Body.Read(loggedData)
|
||||
require.NoError(t, err)
|
||||
assert.Less(t, n, bufSize)
|
||||
// LogCaller was set to false
|
||||
assert.NotContains(t, string(loggedData), "caller")
|
||||
|
||||
// now log again with LogCaller set to true
|
||||
cfg.LogCaller = true
|
||||
loggerMW = middleware.LoggerWithConfig(cfg, rec)
|
||||
|
||||
rec.Flush()
|
||||
|
||||
middlewareChain = loggerMW(logTestHandler)
|
||||
require.NoError(t, middlewareChain(c))
|
||||
|
||||
n, err = rec.Body.Read(loggedData)
|
||||
require.NoError(t, err)
|
||||
assert.Less(t, n, bufSize)
|
||||
// logger_test.go:X should match the line where log.Info() is placed in the logTestHandler
|
||||
assert.Contains(t, string(loggedData[:n]), `"caller":"/app/internal/api/middleware/logger_test.go:17"`)
|
||||
}
|
||||
91
internal/api/middleware/no_cache.go
Normal file
91
internal/api/middleware/no_cache.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package middleware
|
||||
|
||||
// Based on https://github.com/LYY/echo-middleware (MIT License, https://github.com/LYY/echo-middleware/blob/master/LICENSE)
|
||||
// Ported from Goji's middleware, source:
|
||||
// https://github.com/zenazn/goji/tree/master/web/middleware (MIT License, https://github.com/zenazn/goji/blob/master/LICENSE)
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
middleware "github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
type (
|
||||
// NoCacheConfig defines the config for nocache middleware.
|
||||
NoCacheConfig struct {
|
||||
// Skipper defines a function to skip middleware.
|
||||
Skipper middleware.Skipper
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
// Unix epoch time
|
||||
epoch = time.Unix(0, 0).Format(time.RFC1123)
|
||||
|
||||
// Taken from https://github.com/mytrile/nocache
|
||||
noCacheHeaders = map[string]string{
|
||||
"Expires": epoch,
|
||||
"Cache-Control": "no-cache, private, max-age=0",
|
||||
"Pragma": "no-cache",
|
||||
"X-Accel-Expires": "0",
|
||||
}
|
||||
etagHeaders = []string{
|
||||
"ETag",
|
||||
"If-Modified-Since",
|
||||
"If-Match",
|
||||
"If-None-Match",
|
||||
"If-Range",
|
||||
"If-Unmodified-Since",
|
||||
}
|
||||
// DefaultNoCacheConfig is the default nocache middleware config.
|
||||
DefaultNoCacheConfig = NoCacheConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
}
|
||||
)
|
||||
|
||||
// NoCache is a simple piece of middleware that sets a number of HTTP headers to prevent
|
||||
// a router (or subrouter) from being cached by an upstream proxy and/or client.
|
||||
//
|
||||
// As per http://wiki.nginx.org/HttpProxyModule - NoCache sets:
|
||||
//
|
||||
// Expires: Thu, 01 Jan 1970 00:00:00 UTC
|
||||
// Cache-Control: no-cache, private, max-age=0
|
||||
// X-Accel-Expires: 0
|
||||
// Pragma: no-cache (for HTTP/1.0 proxies/clients)
|
||||
func NoCache() echo.MiddlewareFunc {
|
||||
return NoCacheWithConfig(DefaultNoCacheConfig)
|
||||
}
|
||||
|
||||
// NoCacheWithConfig returns a nocache middleware with config.
|
||||
func NoCacheWithConfig(config NoCacheConfig) echo.MiddlewareFunc {
|
||||
// Defaults
|
||||
if config.Skipper == nil {
|
||||
config.Skipper = DefaultNoCacheConfig.Skipper
|
||||
}
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if config.Skipper(c) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
req := c.Request()
|
||||
|
||||
// Delete any ETag headers that may have been set
|
||||
for _, v := range etagHeaders {
|
||||
if req.Header.Get(v) != "" {
|
||||
req.Header.Del(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Set our NoCache headers
|
||||
res := c.Response()
|
||||
for k, v := range noCacheHeaders {
|
||||
res.Header().Set(k, v)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
internal/api/middleware/noop.go
Normal file
11
internal/api/middleware/noop.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/labstack/echo/v4"
|
||||
|
||||
func Noop() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
internal/api/middleware/recover.go
Normal file
15
internal/api/middleware/recover.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func LogErrorFuncWithRequestInfo(c echo.Context, err error, stack []byte) error {
|
||||
log := util.LogFromContext(c.Request().Context())
|
||||
|
||||
log.Error().Err(err).Bytes("stack", stack).Msg("PANIC RECOVER")
|
||||
|
||||
return err
|
||||
}
|
||||
41
internal/api/middleware/recover_test.go
Normal file
41
internal/api/middleware/recover_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLogErrorFuncWithRequestInfo(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
path := "/testing-e87bc94c-2d1f-4342-9ec2-f158c63ac6da"
|
||||
|
||||
s.Echo.Use(echoMiddleware.RecoverWithConfig(echoMiddleware.RecoverConfig{
|
||||
LogErrorFunc: middleware.LogErrorFuncWithRequestInfo,
|
||||
}))
|
||||
|
||||
s.Echo.POST(path, func(c echo.Context) error {
|
||||
// trigger the recover middleware by triggering a nil pointer dereference
|
||||
var val *int
|
||||
_ = *val
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", path, nil, nil)
|
||||
require.Equal(t, http.StatusInternalServerError, res.Result().StatusCode)
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.Snapshoter.SaveString(t, string(body))
|
||||
})
|
||||
}
|
||||
81
internal/api/providers.go
Normal file
81
internal/api/providers.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/i18n"
|
||||
"allaboutapps.dev/aw/go-starter/internal/mailer"
|
||||
"allaboutapps.dev/aw/go-starter/internal/persistence"
|
||||
"allaboutapps.dev/aw/go-starter/internal/push"
|
||||
"allaboutapps.dev/aw/go-starter/internal/push/provider"
|
||||
"github.com/dropbox/godropbox/time2"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// PROVIDERS - define here only providers that for various reasons (e.g. cyclic dependency) can't live in their corresponding packages
|
||||
// or for wrapping providers that only accept sub-configs to prevent the requirements for defining providers for sub-configs.
|
||||
// https://github.com/google/wire/blob/main/docs/guide.md#defining-providers
|
||||
|
||||
// NewPush creates an instance of the push service and registers the configured push providers.
|
||||
func NewPush(cfg config.Server, db *sql.DB) (*push.Service, error) {
|
||||
pusher := push.New(db)
|
||||
|
||||
if cfg.Push.UseFCMProvider {
|
||||
fcmProvider, err := provider.NewFCM(cfg.FCMConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create FCM provider: %w", err)
|
||||
}
|
||||
pusher.RegisterProvider(fcmProvider)
|
||||
}
|
||||
|
||||
if cfg.Push.UseMockProvider {
|
||||
log.Warn().Msg("Initializing mock push provider")
|
||||
mockProvider := provider.NewMock(push.ProviderTypeFCM)
|
||||
pusher.RegisterProvider(mockProvider)
|
||||
}
|
||||
|
||||
if pusher.GetProviderCount() < 1 {
|
||||
log.Warn().Msg("No providers registered for push service")
|
||||
}
|
||||
|
||||
return pusher, nil
|
||||
}
|
||||
|
||||
func NewClock(t ...*testing.T) time2.Clock {
|
||||
var clock time2.Clock
|
||||
|
||||
useMock := len(t) > 0 && t[0] != nil
|
||||
|
||||
if useMock {
|
||||
clock = time2.NewMockClock(time.Now())
|
||||
} else {
|
||||
clock = time2.DefaultClock
|
||||
}
|
||||
|
||||
return clock
|
||||
}
|
||||
|
||||
func NewAuthService(config config.Server, db *sql.DB, clock time2.Clock) *auth.Service {
|
||||
return auth.NewService(config, db, clock)
|
||||
}
|
||||
|
||||
func NewMailer(config config.Server) (*mailer.Mailer, error) {
|
||||
return mailer.NewWithConfig(config.Mailer, config.SMTP)
|
||||
}
|
||||
|
||||
func NewDB(config config.Server) (*sql.DB, error) {
|
||||
return persistence.NewDB(config.Database)
|
||||
}
|
||||
|
||||
func NewI18N(config config.Server) (*i18n.Service, error) {
|
||||
return i18n.New(config.I18n)
|
||||
}
|
||||
|
||||
func NoTest() []*testing.T {
|
||||
return nil
|
||||
}
|
||||
13
internal/api/router/echo_logger.go
Normal file
13
internal/api/router/echo_logger.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package router
|
||||
|
||||
import "github.com/rs/zerolog"
|
||||
|
||||
type echoLogger struct {
|
||||
level zerolog.Level
|
||||
log zerolog.Logger
|
||||
}
|
||||
|
||||
func (l *echoLogger) Write(p []byte) (int, error) {
|
||||
l.log.WithLevel(l.level).Msgf("%s", p)
|
||||
return len(p), nil
|
||||
}
|
||||
27
internal/api/router/echo_renderer.go
Normal file
27
internal/api/router/echo_renderer.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/router/templates"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type echoRenderer struct {
|
||||
templates map[templates.ViewTemplate]*template.Template
|
||||
}
|
||||
|
||||
func (t *echoRenderer) Render(writer io.Writer, name string, data interface{}, _ echo.Context) error {
|
||||
tmplHTML, ok := t.templates[templates.ViewTemplate(name)]
|
||||
if !ok {
|
||||
return fmt.Errorf("template not found: %s", name)
|
||||
}
|
||||
|
||||
if err := tmplHTML.Execute(writer, data); err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
150
internal/api/router/handlers.go
Normal file
150
internal/api/router/handlers.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/timewasted/go-accept-headers"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultHTTPErrorHandlerConfig = HTTPErrorHandlerConfig{
|
||||
HideInternalServerErrorDetails: true,
|
||||
}
|
||||
)
|
||||
|
||||
type HTTPErrorHandlerConfig struct {
|
||||
HideInternalServerErrorDetails bool
|
||||
}
|
||||
|
||||
func HTTPErrorHandler() echo.HTTPErrorHandler {
|
||||
return HTTPErrorHandlerWithConfig(DefaultHTTPErrorHandlerConfig)
|
||||
}
|
||||
|
||||
func HTTPErrorHandlerWithConfig(config HTTPErrorHandlerConfig) echo.HTTPErrorHandler {
|
||||
return func(err error, c echo.Context) {
|
||||
var code int64
|
||||
var resultErr error
|
||||
|
||||
var httpError *httperrors.HTTPError
|
||||
var httpValidationError *httperrors.HTTPValidationError
|
||||
var echoHTTPError *echo.HTTPError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &httpError):
|
||||
code = *httpError.Code
|
||||
resultErr = httpError
|
||||
|
||||
if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails {
|
||||
if httpError.Internal == nil {
|
||||
//nolint:errorlint
|
||||
httpError.Internal = fmt.Errorf("internal error: %s", httpError)
|
||||
}
|
||||
|
||||
httpError.Title = swag.String(http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
case errors.As(err, &httpValidationError):
|
||||
code = *httpValidationError.Code
|
||||
resultErr = httpValidationError
|
||||
|
||||
if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails {
|
||||
if httpValidationError.Internal == nil {
|
||||
//nolint:errorlint
|
||||
httpValidationError.Internal = fmt.Errorf("internal error: %s", httpValidationError)
|
||||
}
|
||||
|
||||
httpValidationError.Title = swag.String(http.StatusText(http.StatusInternalServerError))
|
||||
}
|
||||
case errors.As(err, &echoHTTPError):
|
||||
code = int64(echoHTTPError.Code)
|
||||
|
||||
//nolint:nestif
|
||||
if code == http.StatusInternalServerError && config.HideInternalServerErrorDetails {
|
||||
if echoHTTPError.Internal == nil {
|
||||
//nolint:errorlint
|
||||
echoHTTPError.Internal = fmt.Errorf("internal error: %s", echoHTTPError)
|
||||
}
|
||||
|
||||
resultErr = &httperrors.HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(echoHTTPError.Code)),
|
||||
Title: swag.String(http.StatusText(http.StatusInternalServerError)),
|
||||
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
|
||||
},
|
||||
Internal: echoHTTPError.Internal,
|
||||
}
|
||||
} else {
|
||||
msg, ok := echoHTTPError.Message.(string)
|
||||
if !ok {
|
||||
if m, errr := json.Marshal(msg); err == nil {
|
||||
msg = string(m)
|
||||
} else {
|
||||
msg = fmt.Sprintf("failed to marshal HTTP error message: %v", errr)
|
||||
}
|
||||
}
|
||||
|
||||
resultErr = &httperrors.HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(echoHTTPError.Code)),
|
||||
Title: &msg,
|
||||
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
|
||||
},
|
||||
Internal: echoHTTPError.Internal,
|
||||
}
|
||||
}
|
||||
default:
|
||||
code = http.StatusInternalServerError
|
||||
if config.HideInternalServerErrorDetails {
|
||||
resultErr = &httperrors.HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(http.StatusInternalServerError)),
|
||||
Title: swag.String(http.StatusText(http.StatusInternalServerError)),
|
||||
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
|
||||
},
|
||||
|
||||
Internal: err,
|
||||
}
|
||||
} else {
|
||||
resultErr = &httperrors.HTTPError{
|
||||
PublicHTTPError: types.PublicHTTPError{
|
||||
Code: swag.Int64(int64(http.StatusInternalServerError)),
|
||||
Title: swag.String(err.Error()),
|
||||
Type: types.PublicHTTPErrorTypeGeneric.Pointer(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !c.Response().Committed {
|
||||
if c.Request().Method == http.MethodHead {
|
||||
err = c.NoContent(int(code))
|
||||
} else {
|
||||
err = c.JSON(int(code), resultErr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
util.LogFromEchoContext(c).Warn().Err(err).AnErr("http_err", err).Msg("Failed to handle HTTP error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NotFoundHandler(config config.Server) func(c echo.Context) error {
|
||||
return func(c echo.Context) error {
|
||||
accepted := accept.Parse(c.Request().Header.Get(echo.HeaderAccept))
|
||||
|
||||
if accepted.Accepts(echo.MIMETextHTML) {
|
||||
return c.HTML(http.StatusNotFound, fmt.Sprintf(`<html><body><h1>Page Not Found</h1><p>The page you are looking for does not exist. Did you mean to visit <a href="%s">%s</a>?</p></body></html>`, config.Frontend.BaseURL, config.Frontend.BaseURL))
|
||||
}
|
||||
|
||||
return echo.ErrNotFound
|
||||
}
|
||||
}
|
||||
252
internal/api/router/router.go
Normal file
252
internal/api/router/router.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/handlers/constants"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/router/templates"
|
||||
"github.com/labstack/echo-contrib/echoprometheus"
|
||||
"github.com/labstack/echo/v4"
|
||||
echoMiddleware "github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
// #nosec G108 - pprof handlers (conditionally made available via http.DefaultServeMux)
|
||||
"net/http/pprof"
|
||||
)
|
||||
|
||||
func Init(s *api.Server) error {
|
||||
s.Echo = echo.New()
|
||||
|
||||
viewsRenderer := &echoRenderer{
|
||||
templates: map[templates.ViewTemplate]*template.Template{},
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(s.Config.Echo.WebTemplatesViewsBaseDirAbs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read views templates dir: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
templateName := file.Name()
|
||||
t, err := template.New(templateName).ParseGlob(filepath.Join(s.Config.Echo.WebTemplatesViewsBaseDirAbs, templateName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse template file: %w", err)
|
||||
}
|
||||
|
||||
viewsRenderer.templates[templates.ViewTemplate(templateName)] = t
|
||||
}
|
||||
|
||||
s.Echo.Renderer = viewsRenderer
|
||||
|
||||
s.Echo.Debug = s.Config.Echo.Debug
|
||||
s.Echo.HideBanner = true
|
||||
s.Echo.Logger.SetOutput(&echoLogger{level: s.Config.Logger.RequestLevel, log: log.With().Str("component", "echo").Logger()})
|
||||
echo.NotFoundHandler = NotFoundHandler(s.Config)
|
||||
|
||||
s.Echo.HTTPErrorHandler = HTTPErrorHandlerWithConfig(HTTPErrorHandlerConfig{
|
||||
HideInternalServerErrorDetails: s.Config.Echo.HideInternalServerErrorDetails,
|
||||
})
|
||||
|
||||
// ---
|
||||
// General middleware
|
||||
if s.Config.Management.EnableMetrics {
|
||||
s.Echo.Use(echoprometheus.NewMiddleware(""))
|
||||
err := s.Metrics.RegisterMetrics(context.Background())
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to register metrics")
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Warn().Msg("Disabling metrics middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableTrailingSlashMiddleware {
|
||||
s.Echo.Pre(echoMiddleware.RemoveTrailingSlash())
|
||||
} else {
|
||||
log.Warn().Msg("Disabling trailing slash middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableRecoverMiddleware {
|
||||
s.Echo.Use(echoMiddleware.RecoverWithConfig(echoMiddleware.RecoverConfig{
|
||||
LogErrorFunc: middleware.LogErrorFuncWithRequestInfo,
|
||||
}))
|
||||
} else {
|
||||
log.Warn().Msg("Disabling recover middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableSecureMiddleware {
|
||||
s.Echo.Use(echoMiddleware.SecureWithConfig(echoMiddleware.SecureConfig{
|
||||
Skipper: echoMiddleware.DefaultSecureConfig.Skipper,
|
||||
XSSProtection: s.Config.Echo.SecureMiddleware.XSSProtection,
|
||||
ContentTypeNosniff: s.Config.Echo.SecureMiddleware.ContentTypeNosniff,
|
||||
XFrameOptions: s.Config.Echo.SecureMiddleware.XFrameOptions,
|
||||
HSTSMaxAge: s.Config.Echo.SecureMiddleware.HSTSMaxAge,
|
||||
HSTSExcludeSubdomains: s.Config.Echo.SecureMiddleware.HSTSExcludeSubdomains,
|
||||
ContentSecurityPolicy: s.Config.Echo.SecureMiddleware.ContentSecurityPolicy,
|
||||
CSPReportOnly: s.Config.Echo.SecureMiddleware.CSPReportOnly,
|
||||
HSTSPreloadEnabled: s.Config.Echo.SecureMiddleware.HSTSPreloadEnabled,
|
||||
ReferrerPolicy: s.Config.Echo.SecureMiddleware.ReferrerPolicy,
|
||||
}))
|
||||
} else {
|
||||
log.Warn().Msg("Disabling secure middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableRequestIDMiddleware {
|
||||
s.Echo.Use(echoMiddleware.RequestID())
|
||||
} else {
|
||||
log.Warn().Msg("Disabling request ID middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableLoggerMiddleware {
|
||||
s.Echo.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||
Level: s.Config.Logger.RequestLevel,
|
||||
LogRequestBody: s.Config.Logger.LogRequestBody,
|
||||
LogRequestHeader: s.Config.Logger.LogRequestHeader,
|
||||
LogRequestQuery: s.Config.Logger.LogRequestQuery,
|
||||
LogResponseBody: s.Config.Logger.LogResponseBody,
|
||||
LogResponseHeader: s.Config.Logger.LogResponseHeader,
|
||||
LogCaller: s.Config.Logger.LogCaller,
|
||||
RequestBodyLogSkipper: func(req *http.Request) bool {
|
||||
// We skip all body logging for auth endpoints as these might contain credentials
|
||||
if strings.HasPrefix(req.URL.Path, "/api/v1/auth") {
|
||||
return true
|
||||
}
|
||||
|
||||
return middleware.DefaultRequestBodyLogSkipper(req)
|
||||
},
|
||||
ResponseBodyLogSkipper: func(req *http.Request, res *echo.Response) bool {
|
||||
// We skip all body logging for auth endpoints as these might contain credentials
|
||||
if strings.HasPrefix(req.URL.Path, "/api/v1/auth") {
|
||||
return true
|
||||
}
|
||||
|
||||
return middleware.DefaultResponseBodyLogSkipper(req, res)
|
||||
},
|
||||
Skipper: func(c echo.Context) bool {
|
||||
// We skip logging of readiness and liveness endpoints
|
||||
switch c.Path() {
|
||||
case "/-/ready", "/-/healthy":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
log.Warn().Msg("Disabling logger middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableCORSMiddleware {
|
||||
s.Echo.Use(echoMiddleware.CORS())
|
||||
} else {
|
||||
log.Warn().Msg("Disabling CORS middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Echo.EnableCacheControlMiddleware {
|
||||
s.Echo.Use(middleware.CacheControl())
|
||||
} else {
|
||||
log.Warn().Msg("Disabling cache control middleware due to environment config")
|
||||
}
|
||||
|
||||
if s.Config.Pprof.Enable {
|
||||
pprofAuthMiddleware := middleware.Noop()
|
||||
|
||||
if s.Config.Pprof.EnableManagementKeyAuth {
|
||||
pprofAuthMiddleware = echoMiddleware.KeyAuthWithConfig(echoMiddleware.KeyAuthConfig{
|
||||
KeyLookup: "query:mgmt-secret",
|
||||
Validator: func(key string, _ echo.Context) (bool, error) {
|
||||
return key == s.Config.Management.Secret, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
s.Echo.GET("/debug/pprof", echo.WrapHandler(http.HandlerFunc(pprof.Index)), pprofAuthMiddleware)
|
||||
s.Echo.Any("/debug/pprof/*", echo.WrapHandler(http.DefaultServeMux), pprofAuthMiddleware)
|
||||
|
||||
log.Warn().Bool("EnableManagementKeyAuth", s.Config.Pprof.EnableManagementKeyAuth).Msg("Pprof http handlers are available at /debug/pprof")
|
||||
|
||||
if s.Config.Pprof.RuntimeBlockProfileRate != 0 {
|
||||
runtime.SetBlockProfileRate(s.Config.Pprof.RuntimeBlockProfileRate)
|
||||
log.Warn().Int("RuntimeBlockProfileRate", s.Config.Pprof.RuntimeBlockProfileRate).Msg("Pprof runtime.SetBlockProfileRate")
|
||||
}
|
||||
|
||||
if s.Config.Pprof.RuntimeMutexProfileFraction != 0 {
|
||||
runtime.SetMutexProfileFraction(s.Config.Pprof.RuntimeMutexProfileFraction)
|
||||
log.Warn().Int("RuntimeMutexProfileFraction", s.Config.Pprof.RuntimeMutexProfileFraction).Msg("Pprof runtime.SetMutexProfileFraction")
|
||||
}
|
||||
}
|
||||
|
||||
// Add your custom / additional middlewares here.
|
||||
// see https://echo.labstack.com/middleware
|
||||
|
||||
// ---
|
||||
// Initialize our general groups and set middleware to use above them
|
||||
s.Router = &api.Router{
|
||||
Routes: nil, // will be populated by handlers.AttachAllRoutes(s)
|
||||
|
||||
// Unsecured base group available at /**
|
||||
Root: s.Echo.Group(""),
|
||||
|
||||
// Management endpoints, uncacheable, secured by key auth (query param), available at /-/**
|
||||
Management: s.Echo.Group("/-", echoMiddleware.KeyAuthWithConfig(echoMiddleware.KeyAuthConfig{
|
||||
KeyLookup: "query:mgmt-secret",
|
||||
Validator: func(key string, _ echo.Context) (bool, error) {
|
||||
return key == s.Config.Management.Secret, nil
|
||||
},
|
||||
Skipper: func(c echo.Context) bool {
|
||||
//nolint:gocritic
|
||||
switch c.Path() {
|
||||
case "/-/ready":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}), middleware.NoCache()),
|
||||
|
||||
// OAuth2, unsecured or secured by bearer auth, available at /api/v1/auth/**
|
||||
APIV1Auth: s.Echo.Group("/api/v1/auth", middleware.AuthWithConfig(middleware.AuthConfig{
|
||||
S: s,
|
||||
Mode: middleware.AuthModeRequired,
|
||||
Skipper: func(c echo.Context) bool {
|
||||
switch c.Path() {
|
||||
case "/api/v1/auth/forgot-password",
|
||||
"/api/v1/auth/forgot-password/complete",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/refresh",
|
||||
"/api/v1/auth/register",
|
||||
fmt.Sprintf("/api/v1/auth/register/:%s", constants.RegistrationTokenParam):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
})),
|
||||
WellKnown: s.Echo.Group("/.well-known"),
|
||||
|
||||
// Your other endpoints, typically secured by bearer auth, available at /api/v1/**
|
||||
APIV1Push: s.Echo.Group("/api/v1/push", middleware.Auth(s)),
|
||||
}
|
||||
|
||||
// ---
|
||||
// Finally attach our handlers
|
||||
handlers.AttachAllRoutes(s)
|
||||
|
||||
if s.Config.Management.EnableMetrics {
|
||||
log.Info().Msg("Metrics enabled and available under /metrics")
|
||||
s.Echo.GET("/metrics", echoprometheus.NewHandler())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
128
internal/api/router/router_test.go
Normal file
128
internal/api/router/router_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/metrics/users"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPprofEnabled(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
|
||||
// these are typically our default values, however we force set them here to ensure those are set while test execution.
|
||||
config.Pprof.Enable = true
|
||||
config.Pprof.EnableManagementKeyAuth = true
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
// heap (test any)
|
||||
res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 200, res.Result().StatusCode)
|
||||
|
||||
// index
|
||||
res = test.PerformRequest(t, s, "GET", "/debug/pprof?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 200, res.Result().StatusCode)
|
||||
|
||||
res = test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret=wrongsecret", nil, nil)
|
||||
require.Equal(t, 401, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPprofEnabledNoAuth(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
|
||||
// these are typically our default values, however we force set them here to ensure those are set while test execution.
|
||||
config.Pprof.Enable = true
|
||||
config.Pprof.EnableManagementKeyAuth = false
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?", nil, nil)
|
||||
require.Equal(t, 200, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPprofDisabled(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Pprof.Enable = false
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/debug/pprof/heap?mgmt-secret="+s.Config.Management.Secret, nil, nil)
|
||||
require.Equal(t, 404, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMiddlewaresDisabled(t *testing.T) {
|
||||
// disable all
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Echo.EnableCORSMiddleware = false
|
||||
config.Echo.EnableLoggerMiddleware = false
|
||||
config.Echo.EnableRecoverMiddleware = false
|
||||
config.Echo.EnableRequestIDMiddleware = false
|
||||
config.Echo.EnableSecureMiddleware = false
|
||||
config.Echo.EnableTrailingSlashMiddleware = false
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/-/ready", nil, nil)
|
||||
require.Equal(t, 200, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMetricsEnabled(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
config.Management.EnableMetrics = true
|
||||
|
||||
test.WithTestServerConfigurable(t, config, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/metrics", nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
result := res.Body.String()
|
||||
|
||||
// expect custom metric for the total user count
|
||||
expectedTotalUserCount, err := models.Users().Count(t.Context(), s.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, fmt.Sprintf("%s %d", users.MetricNameTotalUsers, expectedTotalUserCount))
|
||||
|
||||
// expect sqlstats metrics
|
||||
assert.Contains(t, result, "go_sql_stats_connections")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMetricsDisabled(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
res := test.PerformRequest(t, s, "GET", "/metrics", nil, nil)
|
||||
require.Equal(t, http.StatusNotFound, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
t.Run("AcceptApplicationJSON", func(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set(echo.HeaderAccept, echo.MIMEApplicationJSON)
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/api/v1/unknown-path", nil, headers)
|
||||
require.Equal(t, http.StatusNotFound, res.Result().StatusCode)
|
||||
|
||||
test.Snapshoter.Save(t, res.Body.String())
|
||||
})
|
||||
|
||||
t.Run("AcceptTextHTML", func(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set(echo.HeaderAccept, "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/api/v1/unknown-path", nil, headers)
|
||||
require.Equal(t, http.StatusNotFound, res.Result().StatusCode)
|
||||
|
||||
test.Snapshoter.Save(t, res.Body.String())
|
||||
})
|
||||
})
|
||||
}
|
||||
11
internal/api/router/templates/templates.go
Normal file
11
internal/api/router/templates/templates.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package templates
|
||||
|
||||
type ViewTemplate string
|
||||
|
||||
const (
|
||||
ViewTemplateAccountConfirmation ViewTemplate = "account_confirmation.html.tmpl"
|
||||
)
|
||||
|
||||
func (vt ViewTemplate) String() string {
|
||||
return string(vt)
|
||||
}
|
||||
154
internal/api/server.go
Normal file
154
internal/api/server.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/local"
|
||||
"allaboutapps.dev/aw/go-starter/internal/i18n"
|
||||
"allaboutapps.dev/aw/go-starter/internal/mailer"
|
||||
"allaboutapps.dev/aw/go-starter/internal/metrics"
|
||||
"allaboutapps.dev/aw/go-starter/internal/push"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/dropbox/godropbox/time2"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
// Import postgres driver for database/sql package
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
Routes []*echo.Route
|
||||
Root *echo.Group
|
||||
Management *echo.Group
|
||||
APIV1Auth *echo.Group
|
||||
APIV1Push *echo.Group
|
||||
WellKnown *echo.Group
|
||||
}
|
||||
|
||||
// Server is a central struct keeping all the dependencies.
|
||||
// It is initialized with wire, which handles making the new instances of the components
|
||||
// in the right order. To add a new component, 3 steps are required:
|
||||
// - declaring it in this struct
|
||||
// - adding a provider function in providers.go
|
||||
// - adding the provider's function name to the arguments of wire.Build() in wire.go
|
||||
//
|
||||
// Components labeled as `wire:"-"` will be skipped and have to be initialized after the InitNewServer* call.
|
||||
// For more information about wire refer to https://pkg.go.dev/github.com/google/wire
|
||||
type Server struct {
|
||||
// skip wire:
|
||||
// -> initialized with router.Init(s) function
|
||||
Echo *echo.Echo `wire:"-"`
|
||||
Router *Router `wire:"-"`
|
||||
|
||||
Config config.Server
|
||||
DB *sql.DB
|
||||
Mailer *mailer.Mailer
|
||||
Push *push.Service
|
||||
I18n *i18n.Service
|
||||
Clock time2.Clock
|
||||
Auth AuthService
|
||||
Local *local.Service
|
||||
Metrics *metrics.Service
|
||||
}
|
||||
|
||||
// newServerWithComponents is used by wire to initialize the server components.
|
||||
// Components not listed here won't be handled by wire and should be initialized separately.
|
||||
// Components which shouldn't be handled must be labeled `wire:"-"` in Server struct.
|
||||
func newServerWithComponents(
|
||||
cfg config.Server,
|
||||
db *sql.DB,
|
||||
mail *mailer.Mailer,
|
||||
pusher *push.Service,
|
||||
i18n *i18n.Service,
|
||||
clock time2.Clock,
|
||||
auth AuthService,
|
||||
local *local.Service,
|
||||
metrics *metrics.Service,
|
||||
) *Server {
|
||||
return &Server{
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
Mailer: mail,
|
||||
Push: pusher,
|
||||
I18n: i18n,
|
||||
Clock: clock,
|
||||
Auth: auth,
|
||||
Local: local,
|
||||
Metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
type AuthService interface {
|
||||
GetAppUserProfile(ctx context.Context, id string) (*dto.AppUserProfile, error)
|
||||
InitPasswordReset(ctx context.Context, request dto.InitPasswordResetRequest) (dto.InitPasswordResetResult, error)
|
||||
Login(ctx context.Context, request dto.LoginRequest) (dto.LoginResult, error)
|
||||
Logout(ctx context.Context, request dto.LogoutRequest) error
|
||||
Refresh(ctx context.Context, request dto.RefreshRequest) (dto.LoginResult, error)
|
||||
Register(ctx context.Context, request dto.RegisterRequest) (dto.RegisterResult, error)
|
||||
CompleteRegister(ctx context.Context, request dto.CompleteRegisterRequest) (dto.LoginResult, error)
|
||||
DeleteUserAccount(ctx context.Context, request dto.DeleteUserAccountRequest) error
|
||||
ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (dto.LoginResult, error)
|
||||
UpdatePassword(ctx context.Context, request dto.UpdatePasswordRequest) (dto.LoginResult, error)
|
||||
}
|
||||
|
||||
func NewServer(config config.Server) *Server {
|
||||
s := &Server{
|
||||
Config: config,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Ready() bool {
|
||||
if err := util.IsStructInitialized(s); err != nil {
|
||||
log.Debug().Err(err).Msg("Server is not fully initialized")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
if !s.Ready() {
|
||||
return errors.New("server is not ready")
|
||||
}
|
||||
|
||||
if err := s.Echo.Start(s.Config.Echo.ListenAddress); err != nil {
|
||||
return fmt.Errorf("failed to start echo server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) []error {
|
||||
log.Warn().Msg("Shutting down server")
|
||||
|
||||
var errs []error
|
||||
|
||||
if s.DB != nil {
|
||||
log.Debug().Msg("Closing database connection")
|
||||
|
||||
if err := s.DB.Close(); err != nil && !errors.Is(err, sql.ErrConnDone) {
|
||||
log.Error().Err(err).Msg("Failed to close database connection")
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if s.Echo != nil {
|
||||
log.Debug().Msg("Shutting down echo server")
|
||||
|
||||
if err := s.Echo.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error().Err(err).Msg("Failed to shutdown echo server")
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
52
internal/api/wire.go
Normal file
52
internal/api/wire.go
Normal file
@@ -0,0 +1,52 @@
|
||||
//go:build wireinject
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/local"
|
||||
"allaboutapps.dev/aw/go-starter/internal/metrics"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
// INJECTORS - https://github.com/google/wire/blob/main/docs/guide.md#injectors
|
||||
|
||||
// serviceSet groups the default set of providers that are required for initing a server
|
||||
var serviceSet = wire.NewSet(
|
||||
newServerWithComponents,
|
||||
NewPush,
|
||||
NewMailer,
|
||||
NewI18N,
|
||||
authServiceSet,
|
||||
local.NewService,
|
||||
metrics.New,
|
||||
NewClock,
|
||||
)
|
||||
|
||||
var authServiceSet = wire.NewSet(
|
||||
NewAuthService,
|
||||
wire.Bind(new(AuthService), new(*auth.Service)),
|
||||
)
|
||||
|
||||
// InitNewServer returns a new Server instance.
|
||||
func InitNewServer(
|
||||
_ config.Server,
|
||||
) (*Server, error) {
|
||||
wire.Build(serviceSet, NewDB, NoTest)
|
||||
return new(Server), nil
|
||||
}
|
||||
|
||||
// InitNewServerWithDB returns a new Server instance with the given DB instance.
|
||||
// All the other components are initialized via go wire according to the configuration.
|
||||
func InitNewServerWithDB(
|
||||
_ config.Server,
|
||||
_ *sql.DB,
|
||||
t ...*testing.T,
|
||||
) (*Server, error) {
|
||||
wire.Build(serviceSet)
|
||||
return new(Server), nil
|
||||
}
|
||||
94
internal/api/wire_gen.go
Normal file
94
internal/api/wire_gen.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/local"
|
||||
"allaboutapps.dev/aw/go-starter/internal/metrics"
|
||||
"database/sql"
|
||||
"github.com/google/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
import (
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Injectors from wire.go:
|
||||
|
||||
// InitNewServer returns a new Server instance.
|
||||
func InitNewServer(server config.Server) (*Server, error) {
|
||||
db, err := NewDB(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailer, err := NewMailer(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, err := NewPush(server, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i18nService, err := NewI18N(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := NoTest()
|
||||
clock := NewClock(v...)
|
||||
authService := NewAuthService(server, db, clock)
|
||||
localService := local.NewService(server, db, clock)
|
||||
metricsService, err := metrics.New(server, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiServer := newServerWithComponents(server, db, mailer, service, i18nService, clock, authService, localService, metricsService)
|
||||
return apiServer, nil
|
||||
}
|
||||
|
||||
// InitNewServerWithDB returns a new Server instance with the given DB instance.
|
||||
// All the other components are initialized via go wire according to the configuration.
|
||||
func InitNewServerWithDB(server config.Server, db *sql.DB, t ...*testing.T) (*Server, error) {
|
||||
mailer, err := NewMailer(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
service, err := NewPush(server, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i18nService, err := NewI18N(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clock := NewClock(t...)
|
||||
authService := NewAuthService(server, db, clock)
|
||||
localService := local.NewService(server, db, clock)
|
||||
metricsService, err := metrics.New(server, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiServer := newServerWithComponents(server, db, mailer, service, i18nService, clock, authService, localService, metricsService)
|
||||
return apiServer, nil
|
||||
}
|
||||
|
||||
// wire.go:
|
||||
|
||||
// serviceSet groups the default set of providers that are required for initing a server
|
||||
var serviceSet = wire.NewSet(
|
||||
newServerWithComponents,
|
||||
NewPush,
|
||||
NewMailer,
|
||||
NewI18N,
|
||||
authServiceSet, local.NewService, metrics.New, NewClock,
|
||||
)
|
||||
|
||||
var authServiceSet = wire.NewSet(
|
||||
NewAuthService, wire.Bind(new(AuthService), new(*auth.Service)),
|
||||
)
|
||||
5
internal/auth/constants.go
Normal file
5
internal/auth/constants.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package auth
|
||||
|
||||
const (
|
||||
TokenTypeBearer = "bearer"
|
||||
)
|
||||
82
internal/auth/context.go
Normal file
82
internal/auth/context.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// EnrichContextWithCredentials stores the provided credentials in the form of user and access token used for authentication
|
||||
// in the give context and updates the logger associated with ctx to include the user's ID.
|
||||
func EnrichContextWithCredentials(ctx context.Context, result Result) context.Context {
|
||||
// Retrieve current logger associated with context and extend it ID of authenticated user
|
||||
l := util.LogFromContext(ctx).With().Str("userID", result.User.ID).Logger()
|
||||
ctx = l.WithContext(ctx)
|
||||
|
||||
// Store authenticated user's instance in context
|
||||
ctx = context.WithValue(ctx, util.CTXKeyUser, result.User)
|
||||
// Store access token used for authentication in context
|
||||
ctx = context.WithValue(ctx, util.CTXKeyAccessToken, result.Token)
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// EnrichEchoContextWithCredentials stores the provided credentials in the form of user and access token user for authentication
|
||||
// in the given echo context's request and updates the logger associated with c to include the user's ID.
|
||||
func EnrichEchoContextWithCredentials(c echo.Context, result Result) echo.Context {
|
||||
// Get current context and enrich it with credentials
|
||||
req := c.Request()
|
||||
ctx := EnrichContextWithCredentials(req.Context(), result)
|
||||
|
||||
// Set updated request with enriched context in echo context
|
||||
c.SetRequest(req.WithContext(ctx))
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// UserFromContext returns the user model of the currently authenticated user from a context. If no authentication was provided
|
||||
// or the current context does not carry any user information, nil will be returned instead.
|
||||
func UserFromContext(ctx context.Context) *dto.User {
|
||||
u := ctx.Value(util.CTXKeyUser)
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
user, ok := u.(*dto.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// UserFromEchoContext returns the user model of the currently authenticated user from an echo context. If no authentication was
|
||||
// provided or the current echo context does not carry any user information, nil will be returned instead.
|
||||
func UserFromEchoContext(c echo.Context) *dto.User {
|
||||
return UserFromContext(c.Request().Context())
|
||||
}
|
||||
|
||||
// AccessTokenFromContext returns the access token model of the token used to authentication from a context. If no authentication was
|
||||
// provided or the current context does not carry any access token information, nil will be returned instead.
|
||||
func AccessTokenFromContext(ctx context.Context) *string {
|
||||
t := ctx.Value(util.CTXKeyAccessToken)
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
token, ok := t.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return swag.String(token)
|
||||
}
|
||||
|
||||
// AccessTokenFromEchoContext returns the access token model of the token used to authentication from an echo context. If no authentication
|
||||
// was provided or the current context does not carry any access token information, nil will be returned instead.
|
||||
func AccessTokenFromEchoContext(c echo.Context) *string {
|
||||
return AccessTokenFromContext(c.Request().Context())
|
||||
}
|
||||
7
internal/auth/errors.go
Normal file
7
internal/auth/errors.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package auth
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
11
internal/auth/scopes.go
Normal file
11
internal/auth/scopes.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package auth
|
||||
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
ScopeApp Scope = "app"
|
||||
)
|
||||
|
||||
func (s Scope) String() string {
|
||||
return string(s)
|
||||
}
|
||||
620
internal/auth/service.go
Normal file
620
internal/auth/service.go
Normal file
@@ -0,0 +1,620 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/mapper"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/hashing"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/dropbox/godropbox/time2"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
config config.Server
|
||||
db *sql.DB
|
||||
clock time2.Clock
|
||||
}
|
||||
|
||||
func NewService(config config.Server, db *sql.DB, clock time2.Clock) *Service {
|
||||
return &Service{
|
||||
config: config,
|
||||
db: db,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetAppUserProfile(ctx context.Context, userID string) (*dto.AppUserProfile, error) {
|
||||
log := util.LogFromContext(ctx).With().Str("userID", userID).Logger()
|
||||
|
||||
aup, err := models.AppUserProfiles(
|
||||
models.AppUserProfileWhere.UserID.EQ(userID),
|
||||
).One(ctx, s.db)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Debug().Err(err).Msg("AppUserProfile not found")
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
log.Err(err).Msg("Failed to get AppUserProfile")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mapper.LocalAppUserProfileToDTO(aup).Ptr(), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdatePassword(ctx context.Context, request dto.UpdatePasswordRequest) (dto.LoginResult, error) {
|
||||
log := util.LogFromContext(ctx).With().Str("userID", request.User.ID).Logger()
|
||||
|
||||
if !request.User.IsActive {
|
||||
log.Debug().Msg("User is deactivated, rejecting password change")
|
||||
return dto.LoginResult{}, httperrors.ErrForbiddenUserDeactivated
|
||||
}
|
||||
|
||||
if !request.User.PasswordHash.Valid {
|
||||
log.Debug().Msg("Failed to update user password, user is missing password")
|
||||
return dto.LoginResult{}, httperrors.ErrForbiddenNotLocalUser
|
||||
}
|
||||
|
||||
if !request.SkipCurrentPasswordVerification {
|
||||
match, err := hashing.ComparePasswordAndHash(request.CurrentPassword, request.User.PasswordHash.String)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to compare password with stored hash")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
if !match {
|
||||
log.Debug().Msg("Failed to update user password, provided password does not match stored hash")
|
||||
return dto.LoginResult{}, echo.ErrUnauthorized
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := hashing.HashPassword(request.NewPassword, hashing.DefaultArgon2Params)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to hash new password")
|
||||
return dto.LoginResult{}, httperrors.ErrBadRequestInvalidPassword
|
||||
}
|
||||
|
||||
var result dto.LoginResult
|
||||
if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
|
||||
request.User.PasswordHash = null.StringFrom(hash)
|
||||
|
||||
user := request.User.ToModels()
|
||||
|
||||
if _, err := user.Update(ctx, exec, boil.Whitelist(models.UserColumns.Password, models.UserColumns.UpdatedAt)); err != nil {
|
||||
log.Err(err).Msg("Failed to update user")
|
||||
return err
|
||||
}
|
||||
|
||||
result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{
|
||||
User: request.User,
|
||||
InvalidateExistingTokens: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to authenticate user after password change")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to change password")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ResetPassword(ctx context.Context, request dto.ResetPasswordRequest) (dto.LoginResult, error) {
|
||||
log := util.LogFromContext(ctx).With().Logger()
|
||||
|
||||
passwordResetToken, err := models.PasswordResetTokens(
|
||||
models.PasswordResetTokenWhere.Token.EQ(request.ResetToken),
|
||||
qm.Load(models.PasswordResetTokenRels.User),
|
||||
).One(ctx, s.db)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Debug().Err(err).Msg("Password reset token not found")
|
||||
return dto.LoginResult{}, httperrors.ErrNotFoundTokenNotFound
|
||||
}
|
||||
|
||||
log.Err(err).Msg("Failed to load password reset token")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
if s.clock.Now().After(passwordResetToken.ValidUntil) {
|
||||
log.Debug().Time("validUntil", passwordResetToken.ValidUntil).Msg("Password reset token is no longer valid, rejecting password reset")
|
||||
return dto.LoginResult{}, httperrors.ErrConflictTokenExpired
|
||||
}
|
||||
|
||||
return s.UpdatePassword(ctx, dto.UpdatePasswordRequest{
|
||||
User: mapper.LocalUserToDTO(passwordResetToken.R.User),
|
||||
NewPassword: request.NewPassword,
|
||||
SkipCurrentPasswordVerification: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) InitPasswordReset(ctx context.Context, request dto.InitPasswordResetRequest) (dto.InitPasswordResetResult, error) {
|
||||
log := util.LogFromContext(ctx).With().Str("username", request.Username.String()).Logger()
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(null.StringFrom(request.Username.String())),
|
||||
).One(ctx, s.db)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Debug().Err(err).Msg("User not found")
|
||||
return dto.InitPasswordResetResult{}, nil
|
||||
}
|
||||
|
||||
log.Err(err).Err(err).Msg("Failed to load user")
|
||||
return dto.InitPasswordResetResult{}, err
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
log.Debug().Msg("User is deactivated, skipping password reset")
|
||||
return dto.InitPasswordResetResult{}, nil
|
||||
}
|
||||
|
||||
if !user.Password.Valid {
|
||||
log.Debug().Msg("User is missing password, skipping password reset")
|
||||
return dto.InitPasswordResetResult{}, nil
|
||||
}
|
||||
|
||||
if s.config.Auth.PasswordResetTokenDebounceDuration > 0 {
|
||||
resetTokenInDebounceTimeExists, err := user.PasswordResetTokens(
|
||||
models.PasswordResetTokenWhere.CreatedAt.GT(s.clock.Now().Add(-s.config.Auth.PasswordResetTokenDebounceDuration)),
|
||||
models.PasswordResetTokenWhere.ValidUntil.GT(s.clock.Now()),
|
||||
).Exists(ctx, s.db)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to check for existing password reset token")
|
||||
return dto.InitPasswordResetResult{}, err
|
||||
}
|
||||
|
||||
if resetTokenInDebounceTimeExists {
|
||||
log.Debug().Msg("Password reset token exists within debounce time, not sending new one")
|
||||
return dto.InitPasswordResetResult{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var result dto.InitPasswordResetResult
|
||||
if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
|
||||
passwordResetToken, err := user.PasswordResetTokens(
|
||||
models.PasswordResetTokenWhere.CreatedAt.GT(s.clock.Now().Add(-s.config.Auth.PasswordResetTokenReuseDuration)),
|
||||
models.PasswordResetTokenWhere.ValidUntil.GT(s.clock.Now()),
|
||||
).One(ctx, exec)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Debug().Err(err).Msg("No valid password reset token exists, creating new one")
|
||||
|
||||
passwordResetToken = &models.PasswordResetToken{
|
||||
UserID: user.ID,
|
||||
ValidUntil: s.clock.Now().Add(s.config.Auth.PasswordResetTokenValidity),
|
||||
}
|
||||
|
||||
if err := passwordResetToken.Insert(ctx, exec, boil.Infer()); err != nil {
|
||||
log.Err(err).Msg("Failed to insert password reset token")
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Err(err).Msg("Failed to check for existing valid password reset token")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
result.ResetToken = null.StringFrom(passwordResetToken.Token)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to initiate password reset")
|
||||
return dto.InitPasswordResetResult{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, request dto.LogoutRequest) error {
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
|
||||
if _, err := models.AccessTokens(models.AccessTokenWhere.Token.EQ(request.AccessToken)).DeleteAll(ctx, exec); err != nil {
|
||||
log.Err(err).Msg("Failed to delete access token")
|
||||
return err
|
||||
}
|
||||
|
||||
if request.RefreshToken.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := models.RefreshTokens(models.RefreshTokenWhere.Token.EQ(request.RefreshToken.String)).DeleteAll(ctx, exec); err != nil {
|
||||
log.Err(err).Msg("Failed to delete refresh token")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to process logout")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, request dto.LoginRequest) (dto.LoginResult, error) {
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(null.StringFrom(request.Username.String())),
|
||||
).One(ctx, s.db)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Debug().Err(err).Msg("User not found")
|
||||
}
|
||||
|
||||
log.Err(err).Msg("Failed to load user")
|
||||
|
||||
return dto.LoginResult{}, echo.ErrUnauthorized
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
log.Debug().Msg("User is deactivated, rejecting authentication")
|
||||
return dto.LoginResult{}, httperrors.ErrForbiddenUserDeactivated
|
||||
}
|
||||
|
||||
if !user.Password.Valid {
|
||||
log.Debug().Msg("User is missing password, forbidding authentication")
|
||||
return dto.LoginResult{}, echo.ErrUnauthorized
|
||||
}
|
||||
|
||||
match, err := hashing.ComparePasswordAndHash(request.Password, user.Password.String)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to compare password with stored hash")
|
||||
return dto.LoginResult{}, echo.ErrUnauthorized
|
||||
}
|
||||
|
||||
if !match {
|
||||
log.Debug().Msg("Provided password does not match stored hash")
|
||||
return dto.LoginResult{}, echo.ErrUnauthorized
|
||||
}
|
||||
|
||||
var result dto.LoginResult
|
||||
err = db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
|
||||
var err error
|
||||
result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{
|
||||
User: mapper.LocalUserToDTO(user),
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to authenticate user")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to authenticate user")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, request dto.RefreshRequest) (dto.LoginResult, error) {
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
oldRefreshToken, err := models.RefreshTokens(
|
||||
models.RefreshTokenWhere.Token.EQ(request.RefreshToken),
|
||||
qm.Load(models.RefreshTokenRels.User),
|
||||
).One(ctx, s.db)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Debug().Err(err).Msg("Refresh token not found")
|
||||
return dto.LoginResult{}, echo.ErrUnauthorized
|
||||
}
|
||||
|
||||
log.Err(err).Msg("Failed to load refresh token")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
user := oldRefreshToken.R.User
|
||||
|
||||
if !user.IsActive {
|
||||
log.Debug().Msg("User is deactivated, rejecting token refresh")
|
||||
return dto.LoginResult{}, httperrors.ErrForbiddenUserDeactivated
|
||||
}
|
||||
|
||||
var result dto.LoginResult
|
||||
err = db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
|
||||
_, err = oldRefreshToken.Delete(ctx, exec)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to delete old refresh token")
|
||||
return err
|
||||
}
|
||||
|
||||
result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{
|
||||
User: mapper.LocalUserToDTO(user),
|
||||
InvalidateExistingTokens: false,
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to authenticate user")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to refresh token")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, request dto.RegisterRequest) (dto.RegisterResult, error) {
|
||||
log := util.LogFromContext(ctx).With().Str("username", request.Username.String()).Logger()
|
||||
|
||||
user, err := models.Users(
|
||||
models.UserWhere.Username.EQ(null.StringFrom(request.Username.String())),
|
||||
).One(ctx, s.db)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Err(err).Msg("Failed to check whether user exists")
|
||||
return dto.RegisterResult{}, err
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
if !user.RequiresConfirmation {
|
||||
log.Debug().Msg("User with given username already exists")
|
||||
return dto.RegisterResult{}, httperrors.ErrConflictUserAlreadyExists
|
||||
}
|
||||
|
||||
confirmationTokenInDebounceTimeExists, err := user.ConfirmationTokens(
|
||||
models.ConfirmationTokenWhere.CreatedAt.GT(s.clock.Now().Add(-s.config.Auth.ConfirmationTokenDebounceDuration)),
|
||||
models.ConfirmationTokenWhere.ValidUntil.GT(s.clock.Now()),
|
||||
).Exists(ctx, s.db)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to check for existing confirmation token")
|
||||
return dto.RegisterResult{}, err
|
||||
}
|
||||
|
||||
if confirmationTokenInDebounceTimeExists {
|
||||
return dto.RegisterResult{
|
||||
RequiresConfirmation: user.RequiresConfirmation,
|
||||
}, nil
|
||||
}
|
||||
|
||||
confirmationToken := models.ConfirmationToken{
|
||||
UserID: user.ID,
|
||||
ValidUntil: s.clock.Now().Add(s.config.Auth.ConfirmationTokenValidity),
|
||||
}
|
||||
|
||||
if err := confirmationToken.Insert(ctx, s.db, boil.Infer()); err != nil {
|
||||
log.Err(err).Msg("Failed to insert confirmation token")
|
||||
return dto.RegisterResult{}, err
|
||||
}
|
||||
|
||||
return dto.RegisterResult{
|
||||
RequiresConfirmation: user.RequiresConfirmation,
|
||||
ConfirmationToken: null.StringFrom(confirmationToken.Token),
|
||||
}, nil
|
||||
}
|
||||
|
||||
hash, err := hashing.HashPassword(request.Password, hashing.DefaultArgon2Params)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to hash user password")
|
||||
return dto.RegisterResult{}, httperrors.ErrBadRequestInvalidPassword
|
||||
}
|
||||
|
||||
result := dto.RegisterResult{
|
||||
RequiresConfirmation: s.config.Auth.RegistrationRequiresConfirmation,
|
||||
}
|
||||
|
||||
if err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
|
||||
user := &models.User{
|
||||
Username: null.StringFrom(request.Username.String()),
|
||||
Password: null.StringFrom(hash),
|
||||
LastAuthenticatedAt: null.TimeFrom(s.clock.Now()),
|
||||
IsActive: !result.RequiresConfirmation,
|
||||
RequiresConfirmation: result.RequiresConfirmation,
|
||||
Scopes: s.config.Auth.DefaultUserScopes,
|
||||
}
|
||||
|
||||
if err := user.Insert(ctx, exec, boil.Infer()); err != nil {
|
||||
log.Err(err).Msg("Failed to insert user")
|
||||
return err
|
||||
}
|
||||
|
||||
appUserProfile := models.AppUserProfile{
|
||||
UserID: user.ID,
|
||||
}
|
||||
|
||||
if err := appUserProfile.Insert(ctx, exec, boil.Infer()); err != nil {
|
||||
log.Err(err).Msg("Failed to insert app user profile")
|
||||
return err
|
||||
}
|
||||
|
||||
if result.RequiresConfirmation {
|
||||
confirmationToken := models.ConfirmationToken{
|
||||
UserID: user.ID,
|
||||
ValidUntil: s.clock.Now().Add(s.config.Auth.ConfirmationTokenValidity),
|
||||
}
|
||||
|
||||
if err := confirmationToken.Insert(ctx, exec, boil.Infer()); err != nil {
|
||||
log.Err(err).Msg("Failed to insert confirmation token")
|
||||
return err
|
||||
}
|
||||
|
||||
result.ConfirmationToken = null.StringFrom(confirmationToken.Token)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to register user")
|
||||
return dto.RegisterResult{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteUserAccount(ctx context.Context, request dto.DeleteUserAccountRequest) error {
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
if !request.User.IsActive {
|
||||
log.Debug().Msg("User is deactivated, rejecting deletion")
|
||||
return httperrors.ErrForbiddenUserDeactivated
|
||||
}
|
||||
|
||||
if !request.User.PasswordHash.Valid {
|
||||
log.Debug().Msg("Failed to delete user account, user is missing password")
|
||||
return httperrors.ErrForbiddenNotLocalUser
|
||||
}
|
||||
|
||||
match, err := hashing.ComparePasswordAndHash(request.CurrentPassword, request.User.PasswordHash.String)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to compare password with stored hash")
|
||||
return echo.ErrUnauthorized
|
||||
}
|
||||
|
||||
if !match {
|
||||
log.Debug().Msg("Provided password does not match stored hash")
|
||||
return echo.ErrUnauthorized
|
||||
}
|
||||
|
||||
// delete the user and all related data
|
||||
err = db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
|
||||
_, err = models.Users(
|
||||
models.UserWhere.ID.EQ(request.User.ID),
|
||||
).DeleteAll(ctx, exec)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to delete user")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to delete user account")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CompleteRegister(ctx context.Context, request dto.CompleteRegisterRequest) (dto.LoginResult, error) {
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
var result dto.LoginResult
|
||||
err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
|
||||
confirmationToken, err := models.ConfirmationTokens(
|
||||
models.ConfirmationTokenWhere.Token.EQ(request.ConfirmationToken),
|
||||
models.ConfirmationTokenWhere.ValidUntil.GT(s.clock.Now()),
|
||||
qm.Load(models.ConfirmationTokenRels.User),
|
||||
).One(ctx, s.db)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Debug().Err(err).Msg("Confirmation token not found")
|
||||
return httperrors.ErrNotFoundTokenNotFound
|
||||
}
|
||||
|
||||
log.Err(err).Msg("Failed to load confirmation token")
|
||||
return err
|
||||
}
|
||||
|
||||
user := confirmationToken.R.User
|
||||
if user.IsActive || !user.RequiresConfirmation {
|
||||
log.Debug().Msg("User already active, skipping confirmation")
|
||||
return nil
|
||||
}
|
||||
|
||||
user.IsActive = true
|
||||
user.RequiresConfirmation = false
|
||||
if _, err := user.Update(ctx, exec, boil.Whitelist(models.UserColumns.IsActive, models.UserColumns.RequiresConfirmation, models.UserColumns.UpdatedAt)); err != nil {
|
||||
log.Err(err).Msg("Failed to update user")
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := confirmationToken.Delete(ctx, exec); err != nil {
|
||||
log.Err(err).Msg("Failed to delete confirmation token")
|
||||
return err
|
||||
}
|
||||
|
||||
result, err = s.authenticateUser(ctx, exec, dto.AuthenticateUserRequest{
|
||||
User: mapper.LocalUserToDTO(confirmationToken.R.User),
|
||||
})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to authenticate user")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to complete registration")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) authenticateUser(ctx context.Context, exec boil.ContextExecutor, request dto.AuthenticateUserRequest) (dto.LoginResult, error) {
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
result := dto.LoginResult{
|
||||
TokenType: TokenTypeBearer,
|
||||
ExpiresIn: int64(s.config.Auth.AccessTokenValidity.Seconds()),
|
||||
}
|
||||
|
||||
if request.InvalidateExistingTokens {
|
||||
if _, err := models.AccessTokens(
|
||||
models.AccessTokenWhere.UserID.EQ(request.User.ID),
|
||||
).DeleteAll(ctx, exec); err != nil {
|
||||
log.Err(err).Msg("Failed to delete existing access tokens")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
if _, err := models.RefreshTokens(
|
||||
models.RefreshTokenWhere.UserID.EQ(request.User.ID),
|
||||
).DeleteAll(ctx, exec); err != nil {
|
||||
log.Err(err).Msg("Failed to delete existing refresh tokens")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
accessToken := models.AccessToken{
|
||||
ValidUntil: s.clock.Now().Add(s.config.Auth.AccessTokenValidity),
|
||||
UserID: request.User.ID,
|
||||
}
|
||||
|
||||
if err := accessToken.Insert(ctx, exec, boil.Infer()); err != nil {
|
||||
log.Err(err).Msg("Failed to insert access token")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
refreshToken := models.RefreshToken{
|
||||
UserID: request.User.ID,
|
||||
}
|
||||
|
||||
if err := refreshToken.Insert(ctx, exec, boil.Infer()); err != nil {
|
||||
log.Err(err).Msg("Failed to insert refresh token")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
u := request.User.ToModels()
|
||||
u.LastAuthenticatedAt = null.TimeFrom(s.clock.Now())
|
||||
|
||||
if _, err := u.Update(ctx, exec, boil.Whitelist(models.UserColumns.LastAuthenticatedAt, models.UserColumns.UpdatedAt)); err != nil {
|
||||
log.Err(err).Msg("Failed to update user last authenticated time")
|
||||
return dto.LoginResult{}, err
|
||||
}
|
||||
|
||||
result.AccessToken = accessToken.Token
|
||||
result.RefreshToken = refreshToken.Token
|
||||
|
||||
return result, nil
|
||||
}
|
||||
14
internal/auth/types.go
Normal file
14
internal/auth/types.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Token string
|
||||
User *dto.User
|
||||
ValidUntil time.Time
|
||||
Scopes []string
|
||||
}
|
||||
18
internal/config/build_args.go
Normal file
18
internal/config/build_args.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package config
|
||||
|
||||
import "fmt"
|
||||
|
||||
// The following vars are automatically injected via -ldflags.
|
||||
// See Makefile target "make go-build" and make var $(LDFLAGS).
|
||||
// No need to change them here.
|
||||
// https://www.digitalocean.com/community/tutorials/using-ldflags-to-set-version-information-for-go-applications
|
||||
var (
|
||||
ModuleName = "build.local/misses/ldflags" // e.g. "allaboutapps.dev/aw/go-starter"
|
||||
Commit = "< 40 chars git commit hash via ldflags >" // e.g. "59cb7684dd0b0f38d68cd7db657cb614feba8f7e"
|
||||
BuildDate = "1970-01-01T00:00:00+00:00" // e.g. "1970-01-01T00:00:00+00:00"
|
||||
)
|
||||
|
||||
// GetFormattedBuildArgs returns string representation of buildsargs set via ldflags "<ModuleName> @ <Commit> (<BuildDate>)"
|
||||
func GetFormattedBuildArgs() string {
|
||||
return fmt.Sprintf("%v @ %v (%v)", ModuleName, Commit, BuildDate)
|
||||
}
|
||||
57
internal/config/db_config.go
Normal file
57
internal/config/db_config.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
)
|
||||
|
||||
// The DatabaseMigrationTable name is baked into the binary
|
||||
// This setting should always be in sync with dbconfig.yml, sqlboiler.toml and the live database (e.g. to be able to test producation dumps locally)
|
||||
const DatabaseMigrationTable = "migrations"
|
||||
|
||||
// The DatabaseMigrationFolder (folder with all *.sql migrations).
|
||||
// This settings should always be in sync with dbconfig.yaml and Dockerfile (the final app stage).
|
||||
// It's expected that the migrations folder lives at the root of this project or right next to the app binary.
|
||||
var DatabaseMigrationFolder = filepath.Join(util.GetProjectRootDir(), "/migrations")
|
||||
|
||||
type Database struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string `json:"-"` // sensitive
|
||||
Database string
|
||||
AdditionalParams map[string]string `json:"additionalParams,omitempty"` // Optional additional connection parameters mapped into the connection string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
}
|
||||
|
||||
// ConnectionString generates a connection string to be passed to sql.Open or equivalents, assuming Postgres syntax
|
||||
func (c Database) ConnectionString() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s", c.Host, c.Port, c.Username, c.Password, c.Database))
|
||||
|
||||
if _, ok := c.AdditionalParams["sslmode"]; !ok {
|
||||
builder.WriteString(" sslmode=disable")
|
||||
}
|
||||
|
||||
if len(c.AdditionalParams) > 0 {
|
||||
params := make([]string, 0, len(c.AdditionalParams))
|
||||
for param := range c.AdditionalParams {
|
||||
params = append(params, param)
|
||||
}
|
||||
|
||||
sort.Strings(params)
|
||||
|
||||
for _, param := range params {
|
||||
fmt.Fprintf(&builder, " %s=%s", param, c.AdditionalParams[param])
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
68
internal/config/db_config_test.go
Normal file
68
internal/config/db_config_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
)
|
||||
|
||||
// via https://github.com/allaboutapps/integresql/blob/master/pkg/manager/database_config_test.go
|
||||
func TestDatabaseConnectionString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config config.Database
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Simple",
|
||||
config: config.Database{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
Username: "simple",
|
||||
Password: "database_config",
|
||||
Database: "simple_database_config",
|
||||
},
|
||||
want: "host=localhost port=5432 user=simple password=database_config dbname=simple_database_config sslmode=disable",
|
||||
},
|
||||
{
|
||||
name: "SSLMode",
|
||||
config: config.Database{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
Username: "simple",
|
||||
Password: "database_config",
|
||||
Database: "simple_database_config",
|
||||
AdditionalParams: map[string]string{
|
||||
"sslmode": "prefer",
|
||||
},
|
||||
},
|
||||
want: "host=localhost port=5432 user=simple password=database_config dbname=simple_database_config sslmode=prefer",
|
||||
},
|
||||
{
|
||||
name: "Complex",
|
||||
config: config.Database{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
Username: "simple",
|
||||
Password: "database_config",
|
||||
Database: "simple_database_config",
|
||||
AdditionalParams: map[string]string{
|
||||
"connect_timeout": "10",
|
||||
"sslmode": "verify-full",
|
||||
"sslcert": "/app/certs/pg.pem",
|
||||
"sslkey": "/app/certs/pg.key",
|
||||
"sslrootcert": "/app/certs/pg_root.pem",
|
||||
},
|
||||
},
|
||||
want: "host=localhost port=5432 user=simple password=database_config dbname=simple_database_config connect_timeout=10 sslcert=/app/certs/pg.pem sslkey=/app/certs/pg.key sslmode=verify-full sslrootcert=/app/certs/pg_root.pem",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.config.ConnectionString(); got != tt.want {
|
||||
t.Errorf("invalid connection string, got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
71
internal/config/dot_env.go
Normal file
71
internal/config/dot_env.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/subosito/gotenv"
|
||||
)
|
||||
|
||||
// os.SetEnv func signature
|
||||
type envSetter = func(key string, value string) error
|
||||
|
||||
// DotEnvTryLoad forcefully overrides ENV variables through **a maybe available** .env file.
|
||||
//
|
||||
// This function will always remain silent if a .env file does not exist!
|
||||
// If we successfully apply an ENV file, we will log a warning.
|
||||
// If there are any other errors, we will panic!
|
||||
//
|
||||
// This mechanism should only be used **locally** to easily inject (gitignored)
|
||||
// secrets into your ENV. Non-existing .env files are actually the **best case**.
|
||||
//
|
||||
// When running normally (not within tests):
|
||||
// DotEnvTryLoad("/path/tp/my.env.local", os.SetEnv)
|
||||
//
|
||||
// For tests (and autoreset) use t.Setenv:
|
||||
// DotEnvTryLoad("/path/to/my.env.test.local", func(k string, v string) error { t.Setenv(k, v); return nil })
|
||||
func DotEnvTryLoad(absolutePathToEnvFile string, setEnvFn envSetter) {
|
||||
err := DotEnvLoad(absolutePathToEnvFile, setEnvFn)
|
||||
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
log.Panic().Err(err).Str("envFile", absolutePathToEnvFile).Msg(".env parse error!")
|
||||
}
|
||||
} else {
|
||||
log.Warn().Str("envFile", absolutePathToEnvFile).Msg(".env overrides ENV variables!")
|
||||
}
|
||||
}
|
||||
|
||||
// DotEnvLoad forcefully overrides ENV variables through the supplied .env file.
|
||||
//
|
||||
// This mechanism should only be used **locally** to easily inject (gitignored)
|
||||
// secrets into your ENV.
|
||||
//
|
||||
// When running normally (not within tests):
|
||||
// DotEnvLoad("/path/to/my.env.local", os.SetEnv)
|
||||
//
|
||||
// For tests (and ENV var autoreset) use t.Setenv:
|
||||
// DotEnvLoad("/path/to/my.env.test.local", func(k string, v string) error { t.Setenv(k, v); return nil })
|
||||
func DotEnvLoad(absolutePathToEnvFile string, setEnvFn envSetter) error {
|
||||
file, err := os.Open(absolutePathToEnvFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open .env file: %w", err)
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
envs, err := gotenv.StrictParse(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse .env file: %w", err)
|
||||
}
|
||||
|
||||
for key, value := range envs {
|
||||
if err := setEnvFn(key, value); err != nil {
|
||||
return fmt.Errorf("failed to set environment variable: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
63
internal/config/dot_env_test.go
Normal file
63
internal/config/dot_env_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDotEnvOverride(t *testing.T) {
|
||||
assert.Empty(t, os.Getenv("IS_THIS_A_TEST_ENV"))
|
||||
|
||||
orgPsqlUser := os.Getenv("PSQL_USER")
|
||||
|
||||
config.DotEnvTryLoad(
|
||||
filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env1.local"),
|
||||
func(k string, v string) error { t.Setenv(k, v); return nil })
|
||||
|
||||
assert.Equal(t, "yes", os.Getenv("IS_THIS_A_TEST_ENV"))
|
||||
assert.Equal(t, "dotenv_override_psql_user", os.Getenv("PSQL_USER"))
|
||||
assert.Equal(t, orgPsqlUser, os.Getenv("ORIGINAL_PSQL_USER"))
|
||||
|
||||
// override works as expected?
|
||||
config.DotEnvTryLoad(
|
||||
filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env2.local"),
|
||||
func(k string, v string) error { t.Setenv(k, v); return nil })
|
||||
|
||||
assert.Equal(t, "yes still", os.Getenv("IS_THIS_A_TEST_ENV"))
|
||||
assert.NotEqual(t, "dotenv_override_psql_user", os.Getenv("PSQL_USER"))
|
||||
assert.Equal(t, orgPsqlUser, os.Getenv("PSQL_USER"), "Reset to original does not work!")
|
||||
}
|
||||
|
||||
func TestNoopEnvNotFound(t *testing.T) {
|
||||
assert.NotPanics(t, assert.PanicTestFunc(func() {
|
||||
config.DotEnvTryLoad(
|
||||
filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env.does.not.exist"),
|
||||
func(k string, v string) error { t.Setenv(k, v); return nil },
|
||||
)
|
||||
}), "does not panic on file inexistance")
|
||||
}
|
||||
|
||||
func TestEmptyEnv(t *testing.T) {
|
||||
assert.NotPanics(t, assert.PanicTestFunc(func() {
|
||||
config.DotEnvTryLoad(
|
||||
filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env.local.sample"),
|
||||
func(k string, v string) error { t.Setenv(k, v); return nil },
|
||||
)
|
||||
}), "does not panic on file inexistance")
|
||||
|
||||
assert.Empty(t, os.Getenv("EMPTY_VARIABLE_INIT"), "should be empty")
|
||||
}
|
||||
|
||||
func TestPanicsOnEnvMalform(t *testing.T) {
|
||||
assert.Panics(t, assert.PanicTestFunc(func() {
|
||||
config.DotEnvTryLoad(
|
||||
filepath.Join(util.GetProjectRootDir(), "/internal/config/testdata/.env.local.malformed"),
|
||||
func(k string, v string) error { t.Setenv(k, v); return nil },
|
||||
)
|
||||
}), "does panic on file malform")
|
||||
}
|
||||
19
internal/config/mailer_config.go
Normal file
19
internal/config/mailer_config.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package config
|
||||
|
||||
type MailerTransporter string
|
||||
|
||||
var (
|
||||
MailerTransporterMock MailerTransporter = "mock"
|
||||
MailerTransporterSMTP MailerTransporter = "SMTP"
|
||||
)
|
||||
|
||||
func (m MailerTransporter) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
type Mailer struct {
|
||||
DefaultSender string
|
||||
Send bool
|
||||
WebTemplatesEmailBaseDirAbs string
|
||||
Transporter string
|
||||
}
|
||||
258
internal/config/server_config.go
Normal file
258
internal/config/server_config.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/mailer/transport"
|
||||
"allaboutapps.dev/aw/go-starter/internal/push/provider"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/rs/zerolog"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type EchoServer struct {
|
||||
Debug bool
|
||||
ListenAddress string
|
||||
HideInternalServerErrorDetails bool
|
||||
BaseURL string
|
||||
EnableCORSMiddleware bool
|
||||
EnableLoggerMiddleware bool
|
||||
EnableRecoverMiddleware bool
|
||||
EnableRequestIDMiddleware bool
|
||||
EnableTrailingSlashMiddleware bool
|
||||
EnableSecureMiddleware bool
|
||||
EnableCacheControlMiddleware bool
|
||||
SecureMiddleware EchoServerSecureMiddleware
|
||||
WebTemplatesViewsBaseDirAbs string
|
||||
}
|
||||
|
||||
type PprofServer struct {
|
||||
Enable bool
|
||||
EnableManagementKeyAuth bool
|
||||
RuntimeBlockProfileRate int
|
||||
RuntimeMutexProfileFraction int
|
||||
}
|
||||
|
||||
// EchoServerSecureMiddleware represents a subset of echo's secure middleware config relevant to the app server.
|
||||
// https://github.com/labstack/echo/blob/master/middleware/secure.go
|
||||
type EchoServerSecureMiddleware struct {
|
||||
XSSProtection string
|
||||
ContentTypeNosniff string
|
||||
XFrameOptions string
|
||||
HSTSMaxAge int
|
||||
HSTSExcludeSubdomains bool
|
||||
ContentSecurityPolicy string
|
||||
CSPReportOnly bool
|
||||
HSTSPreloadEnabled bool
|
||||
ReferrerPolicy string
|
||||
}
|
||||
|
||||
type AuthServer struct {
|
||||
AccessTokenValidity time.Duration
|
||||
PasswordResetTokenValidity time.Duration
|
||||
PasswordResetTokenDebounceDuration time.Duration
|
||||
PasswordResetTokenReuseDuration time.Duration
|
||||
DefaultUserScopes []string
|
||||
LastAuthenticatedAtThreshold time.Duration
|
||||
RegistrationRequiresConfirmation bool
|
||||
ConfirmationTokenValidity time.Duration
|
||||
ConfirmationTokenDebounceDuration time.Duration
|
||||
}
|
||||
|
||||
type PathsServer struct {
|
||||
APIBaseDirAbs string
|
||||
MntBaseDirAbs string
|
||||
AppleAppSiteAssociationFile string
|
||||
AndroidAssetlinksFile string
|
||||
}
|
||||
|
||||
type ManagementServer struct {
|
||||
Secret string `json:"-"` // sensitive
|
||||
ReadinessTimeout time.Duration
|
||||
LivenessTimeout time.Duration
|
||||
ProbeWriteablePathsAbs []string
|
||||
ProbeWriteableTouchfile string
|
||||
EnableMetrics bool
|
||||
}
|
||||
|
||||
type FrontendServer struct {
|
||||
BaseURL string
|
||||
PasswordResetEndpoint string
|
||||
}
|
||||
|
||||
type LoggerServer struct {
|
||||
Level zerolog.Level
|
||||
RequestLevel zerolog.Level
|
||||
LogRequestBody bool
|
||||
LogRequestHeader bool
|
||||
LogRequestQuery bool
|
||||
LogResponseBody bool
|
||||
LogResponseHeader bool
|
||||
LogCaller bool
|
||||
PrettyPrintConsole bool
|
||||
}
|
||||
|
||||
type I18n struct {
|
||||
DefaultLanguage language.Tag
|
||||
BundleDirAbs string
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Database Database
|
||||
Echo EchoServer
|
||||
Pprof PprofServer
|
||||
Paths PathsServer
|
||||
Auth AuthServer
|
||||
Management ManagementServer
|
||||
Mailer Mailer
|
||||
SMTP transport.SMTPMailTransportConfig
|
||||
Frontend FrontendServer
|
||||
Logger LoggerServer
|
||||
Push PushService
|
||||
FCMConfig provider.FCMConfig
|
||||
I18n I18n
|
||||
}
|
||||
|
||||
// DefaultServiceConfigFromEnv returns the server config as parsed from environment variables
|
||||
// and their respective defaults defined below.
|
||||
// We don't expect that ENV_VARs change while we are running our application or our tests
|
||||
// (and it would be a bad thing to do anyways with parallel testing).
|
||||
// Do NOT use os.Setenv / os.Unsetenv in tests utilizing DefaultServiceConfigFromEnv()!
|
||||
func DefaultServiceConfigFromEnv() Server {
|
||||
// An `.env.local` file in your project root can override the currently set ENV variables.
|
||||
//
|
||||
// We never automatically apply `.env.local` when running "go test" as these ENV variables
|
||||
// may be sensitive (e.g. secrets to external APIs) and applying them modifies the process
|
||||
// global "os.Env" state (it should be applied via t.SetEnv instead).
|
||||
//
|
||||
// If you need dotenv ENV variables available in a test, do that explicitly within that
|
||||
// test before executing DefaultServiceConfigFromEnv (or test.WithTestServer).
|
||||
// See /internal/test/helper_dot_env.go: test.DotEnvLoadLocalOrSkipTest(t)
|
||||
if !testing.Testing() {
|
||||
DotEnvTryLoad(filepath.Join(util.GetProjectRootDir(), ".env.local"), os.Setenv)
|
||||
}
|
||||
|
||||
return Server{
|
||||
Database: Database{
|
||||
Host: util.GetEnv("PGHOST", "postgres"),
|
||||
Port: util.GetEnvAsInt("PGPORT", 5432),
|
||||
Database: util.GetEnv("PGDATABASE", "development"),
|
||||
Username: util.GetEnv("PGUSER", "dbuser"),
|
||||
Password: util.GetEnv("PGPASSWORD", ""),
|
||||
AdditionalParams: map[string]string{
|
||||
"sslmode": util.GetEnv("PGSSLMODE", "disable"),
|
||||
},
|
||||
MaxOpenConns: util.GetEnvAsInt("DB_MAX_OPEN_CONNS", runtime.NumCPU()*2),
|
||||
MaxIdleConns: util.GetEnvAsInt("DB_MAX_IDLE_CONNS", 1),
|
||||
ConnMaxLifetime: time.Second * time.Duration(util.GetEnvAsInt("DB_CONN_MAX_LIFETIME_SEC", 60)),
|
||||
},
|
||||
Echo: EchoServer{
|
||||
Debug: util.GetEnvAsBool("SERVER_ECHO_DEBUG", false),
|
||||
ListenAddress: util.GetEnv("SERVER_ECHO_LISTEN_ADDRESS", ":8080"),
|
||||
HideInternalServerErrorDetails: util.GetEnvAsBool("SERVER_ECHO_HIDE_INTERNAL_SERVER_ERROR_DETAILS", true),
|
||||
BaseURL: util.GetEnv("SERVER_ECHO_BASE_URL", "http://localhost:8080"),
|
||||
EnableCORSMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_CORS_MIDDLEWARE", true),
|
||||
EnableLoggerMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_LOGGER_MIDDLEWARE", true),
|
||||
EnableRecoverMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_RECOVER_MIDDLEWARE", true),
|
||||
EnableRequestIDMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_REQUEST_ID_MIDDLEWARE", true),
|
||||
EnableTrailingSlashMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_TRAILING_SLASH_MIDDLEWARE", true),
|
||||
EnableSecureMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_SECURE_MIDDLEWARE", true),
|
||||
EnableCacheControlMiddleware: util.GetEnvAsBool("SERVER_ECHO_ENABLE_CACHE_CONTROL_MIDDLEWARE", true),
|
||||
// see https://echo.labstack.com/middleware/secure
|
||||
// see https://github.com/labstack/echo/blob/master/middleware/secure.go
|
||||
SecureMiddleware: EchoServerSecureMiddleware{
|
||||
XSSProtection: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_XSS_PROTECTION", "1; mode=block"),
|
||||
ContentTypeNosniff: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_CONTENT_TYPE_NOSNIFF", "nosniff"),
|
||||
XFrameOptions: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_X_FRAME_OPTIONS", "SAMEORIGIN"),
|
||||
HSTSMaxAge: util.GetEnvAsInt("SERVER_ECHO_SECURE_MIDDLEWARE_HSTS_MAX_AGE", 0),
|
||||
HSTSExcludeSubdomains: util.GetEnvAsBool("SERVER_ECHO_SECURE_MIDDLEWARE_HSTS_EXCLUDE_SUBDOMAINS", false),
|
||||
ContentSecurityPolicy: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_CONTENT_SECURITY_POLICY", ""),
|
||||
CSPReportOnly: util.GetEnvAsBool("SERVER_ECHO_SECURE_MIDDLEWARE_CSP_REPORT_ONLY", false),
|
||||
HSTSPreloadEnabled: util.GetEnvAsBool("SERVER_ECHO_SECURE_MIDDLEWARE_HSTS_PRELOAD_ENABLED", false),
|
||||
ReferrerPolicy: util.GetEnv("SERVER_ECHO_SECURE_MIDDLEWARE_REFERRER_POLICY", ""),
|
||||
},
|
||||
WebTemplatesViewsBaseDirAbs: util.GetEnv("SERVER_ECHO_WEB_TEMPLATES_VIEWS_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/web/templates/views")),
|
||||
},
|
||||
Pprof: PprofServer{
|
||||
// https://golang.org/pkg/net/http/pprof/
|
||||
Enable: util.GetEnvAsBool("SERVER_PPROF_ENABLE", false),
|
||||
EnableManagementKeyAuth: util.GetEnvAsBool("SERVER_PPROF_ENABLE_MANAGEMENT_KEY_AUTH", true),
|
||||
RuntimeBlockProfileRate: util.GetEnvAsInt("SERVER_PPROF_RUNTIME_BLOCK_PROFILE_RATE", 0),
|
||||
RuntimeMutexProfileFraction: util.GetEnvAsInt("SERVER_PPROF_RUNTIME_MUTEX_PROFILE_FRACTION", 0),
|
||||
},
|
||||
Paths: PathsServer{
|
||||
// Please ALWAYS work with ABSOLUTE (ABS) paths from ENV_VARS (however you may resolve a project-relative to absolute for the default value)
|
||||
APIBaseDirAbs: util.GetEnv("SERVER_PATHS_API_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/api")), // /app/api (swagger.yml)
|
||||
MntBaseDirAbs: util.GetEnv("SERVER_PATHS_MNT_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/assets/mnt")), // /app/assets/mnt (user-generated content)
|
||||
AppleAppSiteAssociationFile: util.GetEnv("SERVER_PATHS_APPLE_APP_SITE_ASSOCIATION_FILE", ""),
|
||||
AndroidAssetlinksFile: util.GetEnv("SERVER_PATHS_ANDROID_ASSETLINKS_FILE", ""),
|
||||
},
|
||||
Auth: AuthServer{
|
||||
AccessTokenValidity: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_ACCESS_TOKEN_VALIDITY", 86400)),
|
||||
PasswordResetTokenValidity: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_PASSWORD_RESET_TOKEN_VALIDITY", 900)),
|
||||
PasswordResetTokenDebounceDuration: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_PASSWORD_RESET_TOKEN_DEBOUNCE_DURATION_SECONDS", 60)),
|
||||
PasswordResetTokenReuseDuration: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_PASSWORD_RESET_TOKEN_REUSE_DURATION_SECONDS", 0)),
|
||||
DefaultUserScopes: util.GetEnvAsStringArr("SERVER_AUTH_DEFAULT_USER_SCOPES", []string{"app"}),
|
||||
LastAuthenticatedAtThreshold: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_LAST_AUTHENTICATED_AT_THRESHOLD", 900)),
|
||||
RegistrationRequiresConfirmation: util.GetEnvAsBool("SERVER_AUTH_REGISTRATION_REQUIRES_CONFIRMATION", false),
|
||||
ConfirmationTokenValidity: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_CONFIRMATION_TOKEN_VALIDITY_SECONDS", 86400)),
|
||||
ConfirmationTokenDebounceDuration: time.Second * time.Duration(util.GetEnvAsInt("SERVER_AUTH_CONFIRMATION_TOKEN_DEBOUNCE_DURATION_SECONDS", 60)),
|
||||
},
|
||||
Management: ManagementServer{
|
||||
Secret: util.GetMgmtSecret("SERVER_MANAGEMENT_SECRET"),
|
||||
ReadinessTimeout: time.Second * time.Duration(util.GetEnvAsInt("SERVER_MANAGEMENT_READINESS_TIMEOUT_SEC", 4)),
|
||||
LivenessTimeout: time.Second * time.Duration(util.GetEnvAsInt("SERVER_MANAGEMENT_LIVENESS_TIMEOUT_SEC", 9)),
|
||||
ProbeWriteablePathsAbs: util.GetEnvAsStringArr("SERVER_MANAGEMENT_PROBE_WRITEABLE_PATHS_ABS", []string{
|
||||
filepath.Join(util.GetProjectRootDir(), "/assets/mnt")}, ","),
|
||||
ProbeWriteableTouchfile: util.GetEnv("SERVER_MANAGEMENT_PROBE_WRITEABLE_TOUCHFILE", ".healthy"),
|
||||
EnableMetrics: util.GetEnvAsBool("SERVER_MANAGEMENT_ENABLE_METRICS", false),
|
||||
},
|
||||
Mailer: Mailer{
|
||||
DefaultSender: util.GetEnv("SERVER_MAILER_DEFAULT_SENDER", "go-starter@example.com"),
|
||||
Send: util.GetEnvAsBool("SERVER_MAILER_SEND", true),
|
||||
WebTemplatesEmailBaseDirAbs: util.GetEnv("SERVER_MAILER_WEB_TEMPLATES_EMAIL_BASE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/web/templates/email")), // /app/web/templates/email
|
||||
Transporter: util.GetEnvEnum("SERVER_MAILER_TRANSPORTER", MailerTransporterMock.String(), []string{MailerTransporterSMTP.String(), MailerTransporterMock.String()}),
|
||||
},
|
||||
SMTP: transport.SMTPMailTransportConfig{
|
||||
Host: util.GetEnv("SERVER_SMTP_HOST", "mailhog"),
|
||||
Port: util.GetEnvAsInt("SERVER_SMTP_PORT", 1025),
|
||||
Username: util.GetEnv("SERVER_SMTP_USERNAME", ""),
|
||||
Password: util.GetEnv("SERVER_SMTP_PASSWORD", ""),
|
||||
AuthType: transport.SMTPAuthTypeFromString(util.GetEnv("SERVER_SMTP_AUTH_TYPE", transport.SMTPAuthTypeNone.String())),
|
||||
Encryption: transport.SMTPEncryption(util.GetEnvEnum("SERVER_SMTP_ENCRYPTION", transport.SMTPEncryptionNone.String(), []string{transport.SMTPEncryptionNone.String(), transport.SMTPEncryptionTLS.String(), transport.SMTPEncryptionStartTLS.String()})),
|
||||
TLSConfig: nil,
|
||||
},
|
||||
Frontend: FrontendServer{
|
||||
BaseURL: util.GetEnv("SERVER_FRONTEND_BASE_URL", "http://localhost:3000"),
|
||||
PasswordResetEndpoint: util.GetEnv("SERVER_FRONTEND_PASSWORD_RESET_ENDPOINT", "/set-new-password"),
|
||||
},
|
||||
Logger: LoggerServer{
|
||||
Level: util.LogLevelFromString(util.GetEnv("SERVER_LOGGER_LEVEL", zerolog.DebugLevel.String())),
|
||||
RequestLevel: util.LogLevelFromString(util.GetEnv("SERVER_LOGGER_REQUEST_LEVEL", zerolog.DebugLevel.String())),
|
||||
LogRequestBody: util.GetEnvAsBool("SERVER_LOGGER_LOG_REQUEST_BODY", false),
|
||||
LogRequestHeader: util.GetEnvAsBool("SERVER_LOGGER_LOG_REQUEST_HEADER", false),
|
||||
LogRequestQuery: util.GetEnvAsBool("SERVER_LOGGER_LOG_REQUEST_QUERY", false),
|
||||
LogResponseBody: util.GetEnvAsBool("SERVER_LOGGER_LOG_RESPONSE_BODY", false),
|
||||
LogResponseHeader: util.GetEnvAsBool("SERVER_LOGGER_LOG_RESPONSE_HEADER", false),
|
||||
LogCaller: util.GetEnvAsBool("SERVER_LOGGER_LOG_CALLER", false),
|
||||
PrettyPrintConsole: util.GetEnvAsBool("SERVER_LOGGER_PRETTY_PRINT_CONSOLE", false),
|
||||
},
|
||||
Push: PushService{
|
||||
UseFCMProvider: util.GetEnvAsBool("SERVER_PUSH_USE_FCM", false),
|
||||
UseMockProvider: util.GetEnvAsBool("SERVER_PUSH_USE_MOCK", true),
|
||||
},
|
||||
FCMConfig: provider.FCMConfig{
|
||||
GoogleApplicationCredentials: util.GetEnv("GOOGLE_APPLICATION_CREDENTIALS", ""),
|
||||
ProjectID: util.GetEnv("SERVER_FCM_PROJECT_ID", "no-fcm-project-id-set"),
|
||||
ValidateOnly: util.GetEnvAsBool("SERVER_FCM_VALIDATE_ONLY", true),
|
||||
},
|
||||
I18n: I18n{
|
||||
DefaultLanguage: util.GetEnvAsLanguageTag("SERVER_I18N_DEFAULT_LANGUAGE", language.English),
|
||||
BundleDirAbs: util.GetEnv("SERVER_I18N_BUNDLE_DIR_ABS", filepath.Join(util.GetProjectRootDir(), "/web/i18n")), // /app/web/i18n
|
||||
},
|
||||
}
|
||||
}
|
||||
17
internal/config/server_config_test.go
Normal file
17
internal/config/server_config_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
)
|
||||
|
||||
func TestPrintServiceEnv(t *testing.T) {
|
||||
config := config.DefaultServiceConfigFromEnv()
|
||||
_, err := json.MarshalIndent(config, "", " ")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
6
internal/config/service_config.go
Normal file
6
internal/config/service_config.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
type PushService struct {
|
||||
UseFCMProvider bool
|
||||
UseMockProvider bool
|
||||
}
|
||||
1
internal/config/testdata/.env.local.malformed
vendored
Normal file
1
internal/config/testdata/.env.local.malformed
vendored
Normal file
@@ -0,0 +1 @@
|
||||
bla bla
|
||||
2
internal/config/testdata/.env.local.sample
vendored
Normal file
2
internal/config/testdata/.env.local.sample
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# empty value should be ok
|
||||
EMPTY_VARIABLE_INIT=
|
||||
3
internal/config/testdata/.env1.local
vendored
Normal file
3
internal/config/testdata/.env1.local
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
IS_THIS_A_TEST_ENV="yes"
|
||||
ORIGINAL_PSQL_USER="${PSQL_USER}"
|
||||
PSQL_USER="dotenv_override_psql_user"
|
||||
2
internal/config/testdata/.env2.local
vendored
Normal file
2
internal/config/testdata/.env2.local
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
IS_THIS_A_TEST_ENV="yes still"
|
||||
PSQL_USER="${ORIGINAL_PSQL_USER}" # reset back to proper env
|
||||
10
internal/data/dto/push.go
Normal file
10
internal/data/dto/push.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package dto
|
||||
|
||||
import "github.com/aarondl/null/v8"
|
||||
|
||||
type UpdatePushTokenRequest struct {
|
||||
User User
|
||||
Token string
|
||||
Provider string
|
||||
ExistingToken null.String
|
||||
}
|
||||
157
internal/data/dto/users.go
Normal file
157
internal/data/dto/users.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/go-openapi/strfmt/conv"
|
||||
"github.com/go-openapi/swag"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Username null.String
|
||||
PasswordHash null.String
|
||||
IsActive bool
|
||||
Scopes []string
|
||||
LastAuthenticatedAt null.Time
|
||||
UpdatedAt time.Time
|
||||
Profile *AppUserProfile
|
||||
}
|
||||
|
||||
func (u User) LastUpdatedAt() time.Time {
|
||||
if u.Profile != nil && u.Profile.UpdatedAt.After(u.UpdatedAt) {
|
||||
return u.Profile.UpdatedAt
|
||||
}
|
||||
|
||||
return u.UpdatedAt
|
||||
}
|
||||
|
||||
func (u User) Ptr() *User {
|
||||
return &u
|
||||
}
|
||||
|
||||
func (u User) ToTypes() *types.GetUserInfoResponse {
|
||||
return &types.GetUserInfoResponse{
|
||||
Sub: swag.String(u.ID),
|
||||
UpdatedAt: swag.Int64(u.LastUpdatedAt().Unix()),
|
||||
Email: strfmt.Email(u.Username.String),
|
||||
Scopes: u.Scopes,
|
||||
}
|
||||
}
|
||||
|
||||
func (u User) ToModels() *models.User {
|
||||
return &models.User{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Password: u.PasswordHash,
|
||||
IsActive: u.IsActive,
|
||||
Scopes: u.Scopes,
|
||||
LastAuthenticatedAt: u.LastAuthenticatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type AppUserProfile struct {
|
||||
UserID string
|
||||
LegalAcceptedAt null.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (aup AppUserProfile) Ptr() *AppUserProfile {
|
||||
return &aup
|
||||
}
|
||||
|
||||
type UpdatePasswordRequest struct {
|
||||
User User
|
||||
CurrentPassword string
|
||||
SkipCurrentPasswordVerification bool
|
||||
NewPassword string
|
||||
}
|
||||
|
||||
type RegisterResult struct {
|
||||
RequiresConfirmation bool
|
||||
ConfirmationToken null.String
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
UserID string
|
||||
AccessToken string
|
||||
ExpiresIn int64
|
||||
RefreshToken string
|
||||
TokenType string
|
||||
}
|
||||
|
||||
func (l LoginResult) ToTypes() *types.PostLoginResponse {
|
||||
return &types.PostLoginResponse{
|
||||
AccessToken: conv.UUID4(strfmt.UUID4(l.AccessToken)),
|
||||
RefreshToken: conv.UUID4(strfmt.UUID4(l.RefreshToken)),
|
||||
ExpiresIn: swag.Int64(l.ExpiresIn),
|
||||
TokenType: swag.String(l.TokenType),
|
||||
}
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
ResetToken string
|
||||
NewPassword string
|
||||
}
|
||||
|
||||
type Username struct {
|
||||
val string
|
||||
}
|
||||
|
||||
func NewUsername(val string) Username {
|
||||
return Username{val: val}
|
||||
}
|
||||
|
||||
func (u Username) String() string {
|
||||
return util.ToUsernameFormat(u.val)
|
||||
}
|
||||
|
||||
type InitPasswordResetRequest struct {
|
||||
Username Username
|
||||
}
|
||||
|
||||
type InitPasswordResetResult struct {
|
||||
ResetToken null.String
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username Username
|
||||
Password string
|
||||
}
|
||||
|
||||
type LogoutRequest struct {
|
||||
AccessToken string
|
||||
RefreshToken null.String
|
||||
}
|
||||
|
||||
type AuthenticateUserRequest struct {
|
||||
User User
|
||||
InvalidateExistingTokens bool
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username Username
|
||||
Password string
|
||||
}
|
||||
|
||||
type CompleteRegisterRequest struct {
|
||||
ConfirmationToken string
|
||||
}
|
||||
|
||||
type DeleteUserAccountRequest struct {
|
||||
User User
|
||||
CurrentPassword string
|
||||
}
|
||||
|
||||
type ConfirmatioNotificationPayload struct {
|
||||
ConfirmationLink string
|
||||
}
|
||||
36
internal/data/fixtures/fixtures.go
Normal file
36
internal/data/fixtures/fixtures.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package fixtures
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
)
|
||||
|
||||
// Live Service fixtures to be applied by manually running the CLI "app db seed"
|
||||
// Note that these fixtures are not available while testing
|
||||
// see the separate internal/test/fixtures.go file
|
||||
|
||||
type Upsertable interface {
|
||||
Upsert(ctx context.Context, exec boil.ContextExecutor, updateOnConflict bool, conflictColumns []string, updateColumns, insertColumns boil.Columns, opts ...models.UpsertOptionFunc) error
|
||||
}
|
||||
|
||||
// Mind the declaration order! The fields get upserted exactly in the order they are declared.
|
||||
type FixtureMap struct{}
|
||||
|
||||
func Fixtures() FixtureMap {
|
||||
return FixtureMap{}
|
||||
}
|
||||
|
||||
func Upserts() []Upsertable {
|
||||
fix := Fixtures()
|
||||
upsertableIfc := (*Upsertable)(nil)
|
||||
upserts, err := util.GetFieldsImplementing(&fix, upsertableIfc)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to get upsertable fixture fields: %w", err))
|
||||
}
|
||||
|
||||
return upserts
|
||||
}
|
||||
18
internal/data/fixtures/fixtures_test.go
Normal file
18
internal/data/fixtures/fixtures_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package fixtures_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
data "allaboutapps.dev/aw/go-starter/internal/data/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUpsertableInterface(t *testing.T) {
|
||||
var user any = &models.AppUserProfile{
|
||||
UserID: "62b13d29-5c4e-420e-b991-a631d3938776",
|
||||
}
|
||||
|
||||
_, ok := user.(data.Upsertable)
|
||||
assert.True(t, ok, "AppUserProfile should implement the Upsertable interface")
|
||||
}
|
||||
22
internal/data/local/service.go
Normal file
22
internal/data/local/service.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"github.com/dropbox/godropbox/time2"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
config config.Server
|
||||
db *sql.DB
|
||||
clock time2.Clock
|
||||
}
|
||||
|
||||
func NewService(config config.Server, db *sql.DB, clock time2.Clock) *Service {
|
||||
return &Service{
|
||||
config: config,
|
||||
db: db,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
75
internal/data/local/service_push.go
Normal file
75
internal/data/local/service_push.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
)
|
||||
|
||||
func (s *Service) UpdatePushToken(ctx context.Context, request dto.UpdatePushTokenRequest) error {
|
||||
log := util.LogFromContext(ctx).With().Str("userID", request.User.ID).Logger()
|
||||
|
||||
err := db.WithTransaction(ctx, s.db, func(exec boil.ContextExecutor) error {
|
||||
tokenExists, err := models.PushTokens(
|
||||
models.PushTokenWhere.Token.EQ(request.Token),
|
||||
).Exists(ctx, exec)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("Failed to check if token exists")
|
||||
return err
|
||||
}
|
||||
|
||||
if tokenExists {
|
||||
log.Debug().Msg("Token already exists")
|
||||
return httperrors.ErrConflictPushToken
|
||||
}
|
||||
|
||||
newToken := models.PushToken{
|
||||
UserID: request.User.ID,
|
||||
Token: request.Token,
|
||||
Provider: request.Provider,
|
||||
}
|
||||
|
||||
if err := newToken.Insert(ctx, s.db, boil.Infer()); err != nil {
|
||||
log.Err(err).Msg("Failed to insert new token")
|
||||
return err
|
||||
}
|
||||
|
||||
if request.ExistingToken.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
existingToken, err := models.PushTokens(
|
||||
models.PushTokenWhere.Token.EQ(request.ExistingToken.String),
|
||||
models.PushTokenWhere.UserID.EQ(request.User.ID),
|
||||
).One(ctx, exec)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Debug().Msg("Existing token not found")
|
||||
return httperrors.ErrNotFoundOldPushToken
|
||||
}
|
||||
|
||||
log.Err(err).Msg("Failed to find existing token")
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := existingToken.Delete(ctx, exec); err != nil {
|
||||
log.Err(err).Msg("Failed to delete existing token")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to update push token")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
32
internal/data/mapper/users.go
Normal file
32
internal/data/mapper/users.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package mapper
|
||||
|
||||
import (
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
)
|
||||
|
||||
func LocalAppUserProfileToDTO(appUserProfile *models.AppUserProfile) dto.AppUserProfile {
|
||||
return dto.AppUserProfile{
|
||||
UserID: appUserProfile.UserID,
|
||||
LegalAcceptedAt: appUserProfile.LegalAcceptedAt,
|
||||
UpdatedAt: appUserProfile.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func LocalUserToDTO(user *models.User) dto.User {
|
||||
result := dto.User{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
IsActive: user.IsActive,
|
||||
Scopes: user.Scopes,
|
||||
LastAuthenticatedAt: user.LastAuthenticatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
PasswordHash: user.Password,
|
||||
}
|
||||
|
||||
if user.R != nil && user.R.AppUserProfile != nil {
|
||||
result.Profile = LocalAppUserProfileToDTO(user.R.AppUserProfile).Ptr()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
211
internal/i18n/i18n.go
Normal file
211
internal/i18n/i18n.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// i18n, we expect the following:
|
||||
// Your translation toml files should live within /web/i18n and are named according to their supported locale e.g. en.toml, de.toml or en-uk.toml, en-us.toml.
|
||||
//
|
||||
// All translation files should hold the same keys (there are no unique keys on a single bundle locale, apart from pluralization).
|
||||
//
|
||||
// The Service object is created and owned by the api.Server (s.I18n), you typically don't want to create your own object.
|
||||
//
|
||||
// Pluralization obeys to CLDR rules (https://cldr.unicode.org/index/cldr-spec/plural-rules).
|
||||
// Some keywords are reserved for CLDR behaviour, templating and documentation: id, description, hash, leftdelim, rightdelim, zero, one, two, few, many, other
|
||||
// See
|
||||
|
||||
// Service is your convenience object to call Translate/TranslatePlural and match languages according to your loaded translation bundle and its supported languages/locales.
|
||||
type Service struct {
|
||||
bundle *i18n.Bundle
|
||||
matcher language.Matcher
|
||||
}
|
||||
|
||||
// Data should be used to pass your template data
|
||||
type Data map[string]string
|
||||
|
||||
// New returns a new Service struct holding bundle and matcher with the settings of the given config
|
||||
//
|
||||
// Note that Service is typically created and owned by the api.Server (use it via s.I18n)
|
||||
func New(config config.I18n) (*Service, error) {
|
||||
bundle := i18n.NewBundle(config.DefaultLanguage)
|
||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
|
||||
// Load all translation files in each language...
|
||||
files, err := os.ReadDir(config.BundleDirAbs)
|
||||
if err != nil {
|
||||
log.Err(err).Str("dir", config.BundleDirAbs).Msg("Failed to read i18n bundle directory")
|
||||
return nil, fmt.Errorf("failed to read i18n bundle directory: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") {
|
||||
continue
|
||||
}
|
||||
|
||||
// bundle.LoadMessageFile automatically guesses the language.Tag based on the filenames it encounters
|
||||
_, err := bundle.LoadMessageFile(filepath.Join(config.BundleDirAbs, file.Name()))
|
||||
if err != nil {
|
||||
log.Err(err).Str("file", file.Name()).Msg("Failed to load i18n message file")
|
||||
return nil, fmt.Errorf("failed to load i18n message file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tags := bundle.LanguageTags()
|
||||
|
||||
for tagIndex, tag := range tags {
|
||||
// Undetermined languages are disallowed in our bundle.
|
||||
if tag == language.Und {
|
||||
err := fmt.Errorf("undetermined language at index %v in i18n message bundle: %v", tagIndex, tags)
|
||||
log.Err(err).Int("index", tagIndex).Str("tags", fmt.Sprintf("%v", tags)).Msg("Invalid i18n message bundle or default language.")
|
||||
return nil, fmt.Errorf("invalid i18n message bundle or default language: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Service{
|
||||
bundle: bundle,
|
||||
matcher: language.NewMatcher(tags),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Translate your key into a localized string.
|
||||
//
|
||||
// Translate makes a lookup for the key in the current bundle with the specified language.
|
||||
// If a language translation is not available the default language will be used.
|
||||
// Additional data for templated strings can be passed as key value pairs with by passing an optional data map.
|
||||
//
|
||||
// Translate will not fail if a template value is missing "<no value>" will be inserted instead.
|
||||
// Translate will also not fail if the key is not present. "{{key}}" will be returned instead.
|
||||
func (m *Service) Translate(key string, lang language.Tag, data ...Data) string {
|
||||
msg, err := m.TranslateMaybe(key, lang, data...)
|
||||
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("key", key).Str("lang", lang.String()).Msg("Failed to translate")
|
||||
return key
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// TranslateMaybe has the same sematics as Translate with the following exceptions:
|
||||
// It exposes encountered errors (does not automatically log this error) and encountered errors may result in an empty "" string!
|
||||
//
|
||||
// This method may be useful for conditional translation rendering (if key is available, use that, else...).
|
||||
func (m *Service) TranslateMaybe(key string, lang language.Tag, data ...Data) (string, error) {
|
||||
localizeConfig := &i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
}
|
||||
|
||||
if len(data) > 0 {
|
||||
localizeConfig.TemplateData = data[0]
|
||||
}
|
||||
|
||||
return m.translateConfigurable(lang, localizeConfig)
|
||||
}
|
||||
|
||||
// TranslatePlural translates a pluralized cldrKey into a localized string.
|
||||
//
|
||||
// TranslatePlural makes a lookup for the cldrKey (a base key holding CLDR keys like "one" and "other") in the current bundle with the specified language.
|
||||
// This function should be used to conditionally show the pluralized form, controlled by the count param and according to the CLDR rules.
|
||||
//
|
||||
// Note that English and German only support .one and .other CLDR plural rules.
|
||||
// See https://cldr.unicode.org/index/cldr-spec/plural-rules and https://www.unicode.org/cldr/cldr-aux/charts/28/supplemental/language_plural_rules.html
|
||||
//
|
||||
// If a language translation is not available the default language will be used.
|
||||
// Additional data for templated strings can be passed as key value pairs with by passing an optional data map.
|
||||
// The count param is automatically injected into this data map as stringified {{.Count}} and may be overwritten.
|
||||
//
|
||||
// TranslatePlural will not fail if a template value is missing "<no value>" will be inserted instead.
|
||||
// TranslatePlural will also not fail if the cldrKey is not present. "{{cldrKey}} (count={{count}})" will be returned instead.
|
||||
func (m *Service) TranslatePlural(cldrKey string, count interface{}, lang language.Tag, data ...Data) string {
|
||||
msg, err := m.TranslatePluralMaybe(cldrKey, count, lang, data...)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("count", fmt.Sprintf("%v", count)).Str("cldrKey", cldrKey).Str("lang", lang.String()).Msg("Failed to translate plural")
|
||||
return fmt.Sprintf("%s (count=%v)", cldrKey, count)
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// TranslatePluralMaybe uses the same sematics as TranslatePlural with the following exceptions:
|
||||
// It exposes encountered errors (does not automatically log this error) and encountered errors may result in an empty "" string!
|
||||
//
|
||||
// This method may be useful for conditional plural translation rendering (if key is available, use that, else...).
|
||||
func (m *Service) TranslatePluralMaybe(cldrKey string, count interface{}, lang language.Tag, data ...Data) (string, error) {
|
||||
localizeConfig := &i18n.LocalizeConfig{
|
||||
MessageID: cldrKey,
|
||||
PluralCount: count,
|
||||
}
|
||||
|
||||
// We inject Count by default into our template data (for rare usecases you may overwrite it)
|
||||
templateData := make(Data)
|
||||
templateData["Count"] = fmt.Sprintf("%v", count)
|
||||
|
||||
// If optional data was provided, merge them into the templateData map
|
||||
if len(data) > 0 {
|
||||
for k, v := range data[0] {
|
||||
templateData[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
localizeConfig.TemplateData = templateData
|
||||
|
||||
return m.translateConfigurable(lang, localizeConfig)
|
||||
}
|
||||
|
||||
// ParseAcceptLanguage takes the value of the Accept-Language header and returns
|
||||
// the best matched language using the matcher.
|
||||
func (m *Service) ParseAcceptLanguage(lang string) language.Tag {
|
||||
// we deliberately ignore the error returned here, as it will be nil and the matcher will simply pick the default language
|
||||
// this allows us to skip any malformed Accept-Language headers without returning 500 errors to the client
|
||||
// additionally, we don't really care about the q-factor weighting or confidence, the first match will be picked (with a fallback to config.DefaultLanguage)
|
||||
tags, _, err := language.ParseAcceptLanguage(lang)
|
||||
if err != nil {
|
||||
log.Err(err).Str("lang", lang).Msg("Failed to parse accept language")
|
||||
}
|
||||
matchedTag, _, _ := m.matcher.Match(tags...)
|
||||
|
||||
return matchedTag
|
||||
}
|
||||
|
||||
// ParseLang parses the string as language tag and returns
|
||||
// the best matched language using the matcher.
|
||||
func (m *Service) ParseLang(lang string) language.Tag {
|
||||
t, err := language.Parse(lang)
|
||||
if err != nil {
|
||||
log.Err(err).Str("lang", lang).Msg("Failed to parse language")
|
||||
}
|
||||
|
||||
matchedTag, _, _ := m.matcher.Match(t)
|
||||
|
||||
return matchedTag
|
||||
}
|
||||
|
||||
// Tags returns the parsed and priority ordered []language.Tag (your config.DefaultLanguage will be on position 0)
|
||||
func (m *Service) Tags() []language.Tag {
|
||||
return m.bundle.LanguageTags()
|
||||
}
|
||||
|
||||
// translateConfigurable is used internally for fully configurable translations according to our configured language precedence semantics (new Localizer per call).
|
||||
func (m *Service) translateConfigurable(lang language.Tag, localizeConfig *i18n.LocalizeConfig) (string, error) {
|
||||
// We benchmarked precaching all known []i18n.NewLocalizer during initialization,
|
||||
// but it doesn't make a significant difference even with 10000 concurrent * 8 .Translate calls.
|
||||
// Thus we take the easy route and initialize a new localizer with each .Translate or .TranslatePlural call.
|
||||
localizer := i18n.NewLocalizer(m.bundle, lang.String())
|
||||
|
||||
msg, err := localizer.Localize(localizeConfig)
|
||||
if err != nil {
|
||||
return msg, fmt.Errorf("failed to localize: %w", err)
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
589
internal/i18n/i18n_test.go
Normal file
589
internal/i18n/i18n_test.go
Normal file
@@ -0,0 +1,589 @@
|
||||
package i18n_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/i18n"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestServerProvidedI18n(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// uses /app/web/i18n by default
|
||||
// expect all i18n files were loaded and the defaultLanguage matches the FIRST priority Tag.
|
||||
assert.Equal(t, s.Config.I18n.DefaultLanguage, s.I18n.Tags()[0])
|
||||
|
||||
// expect all i18ns were loaded...
|
||||
files, err := os.ReadDir(s.Config.I18n.BundleDirAbs)
|
||||
require.NoError(t, err)
|
||||
|
||||
msgFilesCount := 0
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") {
|
||||
continue
|
||||
}
|
||||
|
||||
msgFilesCount++
|
||||
}
|
||||
|
||||
if msgFilesCount == 0 {
|
||||
// no i18n bundles were available, as the defaultLanguage is a tag itself, check for len 1
|
||||
assert.Len(t, s.I18n.Tags(), 1)
|
||||
} else {
|
||||
assert.Len(t, s.I18n.Tags(), msgFilesCount)
|
||||
}
|
||||
|
||||
msg := s.I18n.Translate("this.key.will.never.exist", s.Config.I18n.DefaultLanguage)
|
||||
assert.Equal(t, "this.key.will.never.exist", msg)
|
||||
})
|
||||
}
|
||||
|
||||
// Note that all following tests use a special message directory within /internal/i18n/testdata.
|
||||
// We do this to ensure we don't depend on your project specific i18n bundle/configuration,
|
||||
// that you would typically store within /web/i18n.
|
||||
|
||||
func TestI18n(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, language.English, srv.Tags()[0])
|
||||
assert.Equal(t, language.German, srv.Tags()[1])
|
||||
assert.Len(t, srv.Tags(), 2)
|
||||
|
||||
msg := srv.Translate("Test.Welcome", language.German, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Guten Tag Hans", msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.Spanish, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Franz"})
|
||||
assert.Equal(t, "Welcome Franz", msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English)
|
||||
assert.Equal(t, "Welcome <no value>", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.German)
|
||||
assert.Equal(t, "Das ist ein Test", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.English)
|
||||
assert.Equal(t, "This is a test", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.Spanish)
|
||||
assert.Equal(t, "This is a test", msg)
|
||||
|
||||
msg = srv.Translate("Test.Invalid.Key.Does.Not.Exist", language.English)
|
||||
assert.Equal(t, "Test.Invalid.Key.Does.Not.Exist", msg)
|
||||
|
||||
msg = srv.Translate("Test.Invalid.Key.Does.Not.Exist", language.German)
|
||||
assert.Equal(t, "Test.Invalid.Key.Does.Not.Exist", msg)
|
||||
|
||||
msg = srv.Translate("Test.String.DE.only", language.English)
|
||||
assert.Equal(t, "Test.String.DE.only", msg)
|
||||
|
||||
msg = srv.Translate("Test.String.DE.only", language.German)
|
||||
assert.Equal(t, "This key only exists in DE", msg)
|
||||
|
||||
msg, err = srv.TranslateMaybe("Test.String.DE.only", language.English)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg) // no fallback!
|
||||
|
||||
msg = srv.Translate("Test.String.EN.only", language.English)
|
||||
assert.Equal(t, "This key only exists in EN", msg)
|
||||
|
||||
msg, err = srv.TranslateMaybe("Test.String.EN.only", language.German)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "This key only exists in EN", msg) // fallback (but error)
|
||||
|
||||
msg = srv.Translate("Test.String.EN.only", language.German)
|
||||
assert.Equal(t, "Test.String.EN.only", msg)
|
||||
|
||||
msg = srv.Translate("", language.German) // empty key
|
||||
assert.Empty(t, msg)
|
||||
|
||||
msg, err = srv.TranslateMaybe("", language.German) // empty key
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg)
|
||||
|
||||
// ensure language subvariants are supported
|
||||
deAt := srv.ParseLang("de-AT")
|
||||
assert.NotEqual(t, language.German, deAt)
|
||||
msg = srv.Translate("Test.Body", deAt)
|
||||
assert.Equal(t, "Das ist ein Test", msg)
|
||||
}
|
||||
|
||||
func TestI18nConcurrentUsage(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(100)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
go func(index int) {
|
||||
msg := srv.Translate("Test.Welcome", language.German, i18n.Data{"Name": strconv.Itoa(index)})
|
||||
assert.Equal(t, fmt.Sprintf("Guten Tag %v", index), msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": strconv.Itoa(index)})
|
||||
assert.Equal(t, fmt.Sprintf("Welcome %v", index), msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.Spanish, i18n.Data{"Name": strconv.Itoa(index)})
|
||||
assert.Equal(t, fmt.Sprintf("Welcome %v", index), msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Franz"})
|
||||
assert.Equal(t, "Welcome Franz", msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English)
|
||||
assert.Equal(t, "Welcome <no value>", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.German)
|
||||
assert.Equal(t, "Das ist ein Test", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.English)
|
||||
assert.Equal(t, "This is a test", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.Spanish)
|
||||
assert.Equal(t, "This is a test", msg)
|
||||
wg.Done()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestI18nOtherDefault(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.German,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, language.German, srv.Tags()[0])
|
||||
assert.Equal(t, language.English, srv.Tags()[1])
|
||||
assert.Len(t, srv.Tags(), 2)
|
||||
}
|
||||
|
||||
func TestI18nInexistantDefault(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.Italian,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, language.Italian, srv.Tags()[0])
|
||||
assert.Equal(t, language.German, srv.Tags()[1])
|
||||
assert.Equal(t, language.English, srv.Tags()[2])
|
||||
assert.Len(t, srv.Tags(), 3)
|
||||
}
|
||||
|
||||
func TestI18nEmpty(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.Italian,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-empty"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, srv.Tags(), 1) // the DefaultLanguage should still be set!
|
||||
assert.Equal(t, language.Italian, srv.Tags()[0])
|
||||
|
||||
tag := srv.ParseAcceptLanguage("de,en-US;q=0.7,en;q=0.3")
|
||||
assert.Equal(t, language.Italian, tag)
|
||||
|
||||
msg := srv.Translate("no.test.key.exists", tag)
|
||||
assert.Equal(t, "no.test.key.exists", msg)
|
||||
|
||||
msg, err = srv.TranslateMaybe("no.test.key.exists", tag)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg)
|
||||
|
||||
msg = srv.Translate("no.test.key.exists", language.Ukrainian)
|
||||
assert.Equal(t, "no.test.key.exists", msg)
|
||||
|
||||
msg, err = srv.TranslateMaybe("no.test.key.exists", language.Ukrainian)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg)
|
||||
}
|
||||
|
||||
func TestI18nSpecialized(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.AmericanEnglish,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-specialized"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, srv.Tags(), 4) // specialized subvariant is default
|
||||
assert.Equal(t, language.AmericanEnglish, srv.Tags()[0])
|
||||
|
||||
msg := srv.Translate("test.punchline", language.AmericanEnglish)
|
||||
assert.Equal(t, "I can has HUMOR?", msg)
|
||||
|
||||
msg = srv.Translate("test.punchline", language.BritishEnglish)
|
||||
assert.Equal(t, "I can has HUMOUR?", msg)
|
||||
|
||||
msg = srv.Translate("test.punchline", language.English)
|
||||
assert.Equal(t, "I can has HUMOR?", msg) // fall back to default
|
||||
|
||||
msg = srv.Translate("test.punchline", language.German)
|
||||
assert.Equal(t, "Habe ich Humor?", msg) // jump to parsed Austrian German
|
||||
|
||||
tag := srv.ParseAcceptLanguage("de-at,en-US;q=0.7,en;q=0.3") // explicit Austrian tag
|
||||
msg = srv.Translate("test.punchline", tag)
|
||||
assert.Equal(t, "Koan i Humor?", msg) // jump to parsed Austrian German
|
||||
}
|
||||
|
||||
func TestReservedKeywordsResolve(t *testing.T) {
|
||||
// "reserved" keys:
|
||||
// "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
|
||||
// see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
|
||||
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-reserved"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, srv.Tags(), 2)
|
||||
|
||||
// single reserved word (not yet mapped, requires 2 keywords at least)
|
||||
msg := srv.Translate("reserved1.zero", language.English)
|
||||
assert.Equal(t, "Zero", msg)
|
||||
|
||||
msg = srv.Translate("reserved2.one", language.English)
|
||||
assert.Equal(t, "One", msg)
|
||||
|
||||
msg = srv.Translate("reserved3.two", language.English)
|
||||
assert.Equal(t, "Two", msg)
|
||||
|
||||
msg = srv.Translate("reserved4.few", language.English)
|
||||
assert.Equal(t, "Few", msg)
|
||||
|
||||
msg = srv.Translate("reserved5.many", language.English)
|
||||
assert.Equal(t, "Many", msg)
|
||||
|
||||
msg = srv.Translate("reserved6.other", language.English)
|
||||
assert.Equal(t, "Other", msg)
|
||||
|
||||
msg = srv.Translate("reserved7.id", language.English)
|
||||
assert.Equal(t, "id", msg)
|
||||
|
||||
msg = srv.Translate("reserved8.description", language.English)
|
||||
assert.Equal(t, "Description", msg)
|
||||
|
||||
// single parent is not directly resolveable
|
||||
msg = srv.Translate("reserved2", language.English)
|
||||
assert.Equal(t, "reserved2", msg)
|
||||
|
||||
// german: single reserved word
|
||||
msg = srv.Translate("reserved1.zero", language.German)
|
||||
assert.Equal(t, "Null", msg)
|
||||
|
||||
msg = srv.Translate("reserved2.one", language.German)
|
||||
assert.Equal(t, "Eins", msg)
|
||||
|
||||
msg = srv.Translate("reserved3.two", language.German)
|
||||
assert.Equal(t, "Zwei", msg)
|
||||
|
||||
msg = srv.Translate("reserved4.few", language.German)
|
||||
assert.Equal(t, "Wenig", msg)
|
||||
|
||||
msg = srv.Translate("reserved5.many", language.German)
|
||||
assert.Equal(t, "Mehr", msg)
|
||||
|
||||
msg = srv.Translate("reserved6.other", language.German)
|
||||
assert.Equal(t, "Andere", msg)
|
||||
|
||||
msg = srv.Translate("reserved7.id", language.German)
|
||||
assert.Equal(t, "ID", msg)
|
||||
|
||||
msg = srv.Translate("reserved8.description", language.German)
|
||||
assert.Equal(t, "Beschreibung", msg)
|
||||
|
||||
// Combined toml map: all reserved words
|
||||
// This does not work as it's parsed as map and CLDR plural rules now apply!
|
||||
// The last key is interpreted as https://cldr.unicode.org/index/cldr-spec/plural-rules
|
||||
msg = srv.Translate("reservedMap.zero", language.English)
|
||||
assert.Equal(t, "reservedMap.zero", msg)
|
||||
|
||||
msg = srv.Translate("reservedMap.one", language.English)
|
||||
assert.Equal(t, "reservedMap.one", msg)
|
||||
|
||||
msg = srv.Translate("reservedMap.two", language.English)
|
||||
assert.Equal(t, "reservedMap.two", msg)
|
||||
|
||||
msg = srv.Translate("reservedMap.few", language.English)
|
||||
assert.Equal(t, "reservedMap.few", msg)
|
||||
|
||||
msg = srv.Translate("reservedMap.many", language.English)
|
||||
assert.Equal(t, "reservedMap.many", msg)
|
||||
|
||||
msg = srv.Translate("reservedMap.other", language.English)
|
||||
assert.Equal(t, "reservedMap.other", msg)
|
||||
|
||||
msg = srv.TranslatePlural("reservedMap", 0, language.English)
|
||||
assert.Equal(t, "Other", msg)
|
||||
|
||||
msg = srv.TranslatePlural("reservedMap", 1, language.English)
|
||||
assert.Equal(t, "One", msg)
|
||||
|
||||
msg = srv.TranslatePlural("reservedMap", 2, language.English)
|
||||
assert.Equal(t, "Other", msg)
|
||||
|
||||
msg = srv.TranslatePlural("reservedMap", "asdfasdf", language.English)
|
||||
assert.Equal(t, "reservedMap (count=asdfasdf)", msg)
|
||||
|
||||
// plain toml: all reserved words
|
||||
// This does not work as it's parsed as map and CLDR plural rules now apply!
|
||||
// The last key is interpreted as https://cldr.unicode.org/index/cldr-spec/plural-rules
|
||||
msg = srv.Translate("reserved.plain.zero", language.English)
|
||||
assert.Equal(t, "reserved.plain.zero", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain.one", language.English)
|
||||
assert.Equal(t, "reserved.plain.one", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain.two", language.English)
|
||||
assert.Equal(t, "reserved.plain.two", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain.few", language.English)
|
||||
assert.Equal(t, "reserved.plain.few", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain.many", language.English)
|
||||
assert.Equal(t, "reserved.plain.many", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain.other", language.English)
|
||||
assert.Equal(t, "reserved.plain.other", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain2.id", language.English)
|
||||
assert.Equal(t, "reserved.plain2.id", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain2.description", language.English)
|
||||
assert.Equal(t, "reserved.plain2.description", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain3.id", language.English)
|
||||
assert.Equal(t, "reserved.plain3.id", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain3.description", language.English)
|
||||
assert.Equal(t, "reserved.plain3.description", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain3.test", language.English)
|
||||
assert.Equal(t, "reserved.plain3.test", msg)
|
||||
}
|
||||
|
||||
func TestI18nPlural(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-plural"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, srv.Tags(), 2)
|
||||
|
||||
msg := srv.TranslatePlural("cats", 0, language.AmericanEnglish)
|
||||
assert.Equal(t, "I've 0 cats.", msg) // zero is not supported for CLDR English, "I don't have a cat." is not possible!
|
||||
|
||||
msg = srv.TranslatePlural("cats", 1, language.BritishEnglish)
|
||||
assert.Equal(t, "I've one cat.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", 2, language.English)
|
||||
assert.Equal(t, "I've 2 cats.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", 8, language.English)
|
||||
assert.Equal(t, "I've 8 cats.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", -1, language.English) // negative and positive scales behave the same!
|
||||
assert.Equal(t, "I've one cat.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", -2, language.English) // negative and positive scales behave the same!
|
||||
assert.Equal(t, "I've -2 cats.", msg)
|
||||
|
||||
msg, err = srv.TranslatePluralMaybe("cats", -2, language.English)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "I've -2 cats.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", nil, language.English)
|
||||
assert.Equal(t, "I've <nil> cats.", msg) // invalid count
|
||||
|
||||
msg, err = srv.TranslatePluralMaybe("cats", nil, language.English)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "I've <nil> cats.", msg) // invalid count
|
||||
|
||||
msg = srv.TranslatePlural("cats", "many", language.English)
|
||||
assert.Equal(t, "cats (count=many)", msg) // internal failed to translate plural!
|
||||
|
||||
// overwrite Count
|
||||
msg = srv.TranslatePlural("cats", 8, language.English, i18n.Data{"Count": "too many"})
|
||||
assert.Equal(t, "I've too many cats.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", 0, language.German)
|
||||
assert.Equal(t, "Ich habe 0 Katzen.", msg) // zero is not supported for CLDR German, "Ich habe keine Katze." is not possible!
|
||||
|
||||
msg = srv.TranslatePlural("cats", 1, language.German)
|
||||
assert.Equal(t, "Ich habe eine Katze.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", 2, language.German)
|
||||
assert.Equal(t, "Ich habe 2 Katzen.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", 8, language.German)
|
||||
assert.Equal(t, "Ich habe 8 Katzen.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", "viele", language.German)
|
||||
assert.Equal(t, "cats (count=viele)", msg) // internal failed to translate plural!
|
||||
|
||||
msg, err = srv.TranslatePluralMaybe("cats", "viele", language.German)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg) // empty string for errors!
|
||||
|
||||
// overwrite Count
|
||||
msg = srv.TranslatePlural("cats", 8, language.German, i18n.Data{"Count": "zu viele"})
|
||||
assert.Equal(t, "Ich habe zu viele Katzen.", msg)
|
||||
|
||||
// unknown language string
|
||||
tag := srv.ParseLang("xx")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg = srv.TranslatePlural("cats", 8, tag)
|
||||
assert.Equal(t, "I've 8 cats.", msg) // fall back to English
|
||||
|
||||
// invalid specialized language string
|
||||
tag = srv.ParseLang("de-xx")
|
||||
msg = srv.TranslatePlural("cats", 8, tag)
|
||||
assert.Equal(t, "Ich habe 8 Katzen.", msg) // fall back to German
|
||||
|
||||
// invalid language string
|
||||
tag = srv.ParseLang("§$%/%&/(/&%/)(")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg = srv.TranslatePlural("cats", 8, tag)
|
||||
assert.Equal(t, "I've 8 cats.", msg) // fall back to English
|
||||
|
||||
// unknown
|
||||
msg = srv.TranslatePlural("this.key.will.never.exist", nil, language.English)
|
||||
assert.Equal(t, "this.key.will.never.exist (count=<nil>)", msg)
|
||||
|
||||
msg, err = srv.TranslatePluralMaybe("this.key.will.never.exist", nil, language.English)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg) // empty string for errors!
|
||||
}
|
||||
|
||||
func TestI18nUndetermined(t *testing.T) {
|
||||
_, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-undetermined"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
unwrappedErr := errors.Unwrap(err)
|
||||
require.Error(t, unwrappedErr)
|
||||
assert.Equal(t, unwrappedErr, errors.New("undetermined language at index 1 in i18n message bundle: [en und]"))
|
||||
}
|
||||
|
||||
func TestI18nUndeterminedDefaultLanguage(t *testing.T) {
|
||||
_, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.Und, // Undetermined is disallowed
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
unwrappedErr := errors.Unwrap(err)
|
||||
require.Error(t, unwrappedErr)
|
||||
assert.Equal(t, unwrappedErr, errors.New("undetermined language at index 0 in i18n message bundle: [und de en]"))
|
||||
}
|
||||
|
||||
func TestI18nInvalidToml(t *testing.T) {
|
||||
_, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-invalid"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestI18nInexistantFolder(t *testing.T) {
|
||||
_, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.AmericanEnglish,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n/this/folder/does/not/exist"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseAcceptLanguage(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
tag := srv.ParseAcceptLanguage("de,en-US;q=0.7,en;q=0.3")
|
||||
assert.Equal(t, language.German, tag)
|
||||
|
||||
tag = srv.ParseAcceptLanguage("de-AT,en-US;q=0.7,en;q=0.3")
|
||||
assert.NotEqual(t, language.German, tag) // actual: de-u-rg-atzzzz
|
||||
|
||||
// unknown language header
|
||||
tag = srv.ParseAcceptLanguage("xx,en-US;q=0.7,en;q=0.3")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg := srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
|
||||
// invalid specialized language string
|
||||
tag = srv.ParseAcceptLanguage("de-xx,en-US;q=0.7,en;q=0.3")
|
||||
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Guten Tag Hans", msg)
|
||||
|
||||
// invalid language header
|
||||
tag = srv.ParseAcceptLanguage("§$%/%&/(/&%/)(")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
}
|
||||
|
||||
func TestParseLanguage(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
tag := srv.ParseLang("de")
|
||||
assert.Equal(t, language.German, tag)
|
||||
|
||||
// unknown language string
|
||||
tag = srv.ParseLang("xx")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg := srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
|
||||
// invalid specialized language string
|
||||
tag = srv.ParseLang("de-xx")
|
||||
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Guten Tag Hans", msg) // fall back to German
|
||||
|
||||
// invalid language string
|
||||
tag = srv.ParseLang("§$%/%&/(/&%/)(")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
}
|
||||
3
internal/i18n/testdata/README.md
vendored
Normal file
3
internal/i18n/testdata/README.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# `internal/i18n/testdata`
|
||||
|
||||
These translation toml files are not meant to be touched while doing application development, rather they are here to test the baseline functionality of the i18n package.
|
||||
0
internal/i18n/testdata/i18n-empty/.gitkeep
vendored
Normal file
0
internal/i18n/testdata/i18n-empty/.gitkeep
vendored
Normal file
2
internal/i18n/testdata/i18n-invalid/en.toml
vendored
Normal file
2
internal/i18n/testdata/i18n-invalid/en.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# This file in invalid on purpose.
|
||||
This is not toml.
|
||||
13
internal/i18n/testdata/i18n-plural/de.toml
vendored
Normal file
13
internal/i18n/testdata/i18n-plural/de.toml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# {{.Count}} is injected by default while using service.TranslatePlural
|
||||
|
||||
# pluralized structured format
|
||||
[cats]
|
||||
zero="Ich habe keine Katze."
|
||||
one="Ich habe eine Katze."
|
||||
other="Ich habe {{.Count}} Katzen."
|
||||
|
||||
# pluralized single line format
|
||||
"dogs.zero"="Ich habe keinen Hund."
|
||||
"dogs.one"="Ich habe einen Hund."
|
||||
"dogs.other"="Ich habe {{.Count}} Hunde."
|
||||
|
||||
12
internal/i18n/testdata/i18n-plural/en.toml
vendored
Normal file
12
internal/i18n/testdata/i18n-plural/en.toml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# {{.Count}} is injected by default while using service.TranslatePlural
|
||||
|
||||
# pluralized structured format
|
||||
[cats]
|
||||
zero="I don't have a cat."
|
||||
one="I've one cat."
|
||||
other="I've {{.Count}} cats."
|
||||
|
||||
# pluralized single line format
|
||||
"dogs.zero"="I don't have a dog."
|
||||
"dogs.one"="I've one dog."
|
||||
"dogs.other"="I've {{.Count}} dogs."
|
||||
34
internal/i18n/testdata/i18n-reserved/de.toml
vendored
Normal file
34
internal/i18n/testdata/i18n-reserved/de.toml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# "reserved" keys:
|
||||
# "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
|
||||
# see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
|
||||
|
||||
"reserved1.zero"="Null"
|
||||
"reserved2.one"="Eins"
|
||||
"reserved3.two"="Zwei"
|
||||
"reserved4.few"="Wenig"
|
||||
"reserved5.many"="Mehr"
|
||||
"reserved6.other"="Andere"
|
||||
"reserved7.id"="ID"
|
||||
"reserved8.description"="Beschreibung"
|
||||
|
||||
[reservedMap]
|
||||
zero="Null"
|
||||
one="Eins"
|
||||
two="Zwei"
|
||||
few="Wenig"
|
||||
many="Mehr"
|
||||
other="Andere"
|
||||
|
||||
"reserved.plain.zero"="Null"
|
||||
"reserved.plain.one"="Eins"
|
||||
"reserved.plain.two"="Zwei"
|
||||
"reserved.plain.few"="Wenig"
|
||||
"reserved.plain.many"="Mehr"
|
||||
"reserved.plain.other"="Andere"
|
||||
|
||||
"reserved.plain2.id"="ID"
|
||||
"reserved.plain2.description"="Beschreibung"
|
||||
|
||||
"reserved.plain3.id"="ID"
|
||||
"reserved.plain3.description"="Beschreibung"
|
||||
"reserved.plain3.test"="Test" # can i see this non-reserved one by FQDN?
|
||||
34
internal/i18n/testdata/i18n-reserved/en.toml
vendored
Normal file
34
internal/i18n/testdata/i18n-reserved/en.toml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# "reserved" keys:
|
||||
# "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
|
||||
# see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
|
||||
|
||||
"reserved1.zero"="Zero"
|
||||
"reserved2.one"="One"
|
||||
"reserved3.two"="Two"
|
||||
"reserved4.few"="Few"
|
||||
"reserved5.many"="Many"
|
||||
"reserved6.other"="Other"
|
||||
"reserved7.id"="id"
|
||||
"reserved8.description"="Description"
|
||||
|
||||
[reservedMap]
|
||||
zero="Zero"
|
||||
one="One"
|
||||
two="Two"
|
||||
few="Few"
|
||||
many="Many"
|
||||
other="Other"
|
||||
|
||||
"reserved.plain.zero"="Zero"
|
||||
"reserved.plain.one"="One"
|
||||
"reserved.plain.two"="Two"
|
||||
"reserved.plain.few"="Few"
|
||||
"reserved.plain.many"="Many"
|
||||
"reserved.plain.other"="Other"
|
||||
|
||||
"reserved.plain2.id"="id"
|
||||
"reserved.plain2.description"="Description"
|
||||
|
||||
"reserved.plain3.id"="id"
|
||||
"reserved.plain3.description"="Description"
|
||||
"reserved.plain3.test"="Test" # can i see this non-reserved one by FQDN?
|
||||
2
internal/i18n/testdata/i18n-specialized/de-at.toml
vendored
Normal file
2
internal/i18n/testdata/i18n-specialized/de-at.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
punchline="Koan i Humor?"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user