Support for password resets

This commit is contained in:
Ben Toogood
2021-02-19 11:45:33 +00:00
parent eef581b94c
commit e677c40840
39 changed files with 2394 additions and 1097 deletions

74
users/handler/create.go Normal file
View File

@@ -0,0 +1,74 @@
package handler
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
pb "github.com/micro/services/users/proto"
"gorm.io/gorm"
)
// Create a user
func (u *Users) Create(ctx context.Context, req *pb.CreateRequest, rsp *pb.CreateResponse) error {
// validate the request
if len(req.FirstName) == 0 {
return ErrMissingFirstName
}
if len(req.LastName) == 0 {
return ErrMissingLastName
}
if len(req.Email) == 0 {
return ErrMissingEmail
}
if !isEmailValid(req.Email) {
return ErrInvalidEmail
}
if len(req.Password) < 8 {
return ErrInvalidPassword
}
// hash and salt the password using bcrypt
phash, err := hashAndSalt(req.Password)
if err != nil {
logger.Errorf("Error hasing and salting password: %v", err)
return errors.InternalServerError("HASHING_ERROR", "Error hashing password")
}
return u.DB.Transaction(func(tx *gorm.DB) error {
// write the user to the database
user := &User{
ID: uuid.New().String(),
FirstName: req.FirstName,
LastName: req.LastName,
Email: strings.ToLower(req.Email),
Password: phash,
}
err = u.DB.Create(user).Error
if err != nil && strings.Contains(err.Error(), "idx_users_email") {
return ErrDuplicateEmail
} else if err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// generate a token for the user
token := Token{
UserID: user.ID,
Key: uuid.New().String(),
ExpiresAt: u.Time().Add(time.Hour * 24 * 7),
}
if err := tx.Create(&token).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.User = user.Serialize()
rsp.Token = token.Key
return nil
})
}

View File

@@ -0,0 +1,129 @@
package handler_test
import (
"context"
"testing"
"github.com/micro/services/users/handler"
pb "github.com/micro/services/users/proto"
"github.com/stretchr/testify/assert"
)
func TestCreate(t *testing.T) {
tt := []struct {
Name string
FirstName string
LastName string
Email string
Password string
Error error
}{
{
Name: "MissingFirstName",
LastName: "Doe",
Email: "john@doe.com",
Password: "password",
Error: handler.ErrMissingFirstName,
},
{
Name: "MissingLastName",
FirstName: "John",
Email: "john@doe.com",
Password: "password",
Error: handler.ErrMissingLastName,
},
{
Name: "MissingEmail",
FirstName: "John",
LastName: "Doe",
Password: "password",
Error: handler.ErrMissingEmail,
},
{
Name: "InvalidEmail",
FirstName: "John",
LastName: "Doe",
Password: "password",
Email: "foo.foo.foo",
Error: handler.ErrInvalidEmail,
},
{
Name: "InvalidPassword",
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "pwd",
Error: handler.ErrInvalidPassword,
},
}
// test the validations
h := testHandler(t)
for _, tc := range tt {
t.Run(tc.Name, func(t *testing.T) {
err := h.Create(context.TODO(), &pb.CreateRequest{
FirstName: tc.FirstName,
LastName: tc.LastName,
Email: tc.Email,
Password: tc.Password,
}, &pb.CreateResponse{})
assert.Equal(t, tc.Error, err)
})
}
t.Run("Valid", func(t *testing.T) {
var rsp pb.CreateResponse
req := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &req, &rsp)
assert.NoError(t, err)
u := rsp.User
if u == nil {
t.Fatalf("No user returned")
}
assert.NotEmpty(t, u.Id)
assert.Equal(t, req.FirstName, u.FirstName)
assert.Equal(t, req.LastName, u.LastName)
assert.Equal(t, req.Email, u.Email)
assert.NotEmpty(t, rsp.Token)
})
t.Run("DuplicateEmail", func(t *testing.T) {
var rsp pb.CreateResponse
req := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &req, &rsp)
assert.Equal(t, handler.ErrDuplicateEmail, err)
assert.Nil(t, rsp.User)
})
t.Run("DifferentEmail", func(t *testing.T) {
var rsp pb.CreateResponse
req := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "johndoe@gmail.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &req, &rsp)
assert.NoError(t, err)
u := rsp.User
if u == nil {
t.Fatalf("No user returned")
}
assert.NotEmpty(t, u.Id)
assert.Equal(t, req.FirstName, u.FirstName)
assert.Equal(t, req.LastName, u.LastName)
assert.Equal(t, req.Email, u.Email)
})
}

34
users/handler/delete.go Normal file
View File

@@ -0,0 +1,34 @@
package handler
import (
"context"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
pb "github.com/micro/services/users/proto"
"gorm.io/gorm"
)
// Delete a user
func (u *Users) Delete(ctx context.Context, req *pb.DeleteRequest, rsp *pb.DeleteResponse) error {
// validate the request
if len(req.Id) == 0 {
return ErrMissingID
}
// delete the users tokens
return u.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(&Token{}, &Token{UserID: req.Id}).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// delete from the database
if err := tx.Delete(&User{}, &User{ID: req.Id}).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
return nil
})
}

View File

@@ -0,0 +1,56 @@
package handler_test
import (
"context"
"testing"
"github.com/micro/services/users/handler"
pb "github.com/micro/services/users/proto"
"github.com/stretchr/testify/assert"
)
func TestDelete(t *testing.T) {
h := testHandler(t)
t.Run("MissingID", func(t *testing.T) {
err := h.Delete(context.TODO(), &pb.DeleteRequest{}, &pb.DeleteResponse{})
assert.Equal(t, handler.ErrMissingID, err)
})
// create some mock data
var cRsp pb.CreateResponse
cReq := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq, &cRsp)
assert.NoError(t, err)
if cRsp.User == nil {
t.Fatal("No user returned")
return
}
t.Run("Valid", func(t *testing.T) {
err := h.Delete(context.TODO(), &pb.DeleteRequest{
Id: cRsp.User.Id,
}, &pb.DeleteResponse{})
assert.NoError(t, err)
// check it was actually deleted
var rsp pb.ReadResponse
err = h.Read(context.TODO(), &pb.ReadRequest{
Ids: []string{cRsp.User.Id},
}, &rsp)
assert.NoError(t, err)
assert.Nil(t, rsp.Users[cRsp.User.Id])
})
t.Run("Retry", func(t *testing.T) {
err := h.Delete(context.TODO(), &pb.DeleteRequest{
Id: cRsp.User.Id,
}, &pb.DeleteResponse{})
assert.NoError(t, err)
})
}

View File

@@ -1,14 +1,10 @@
package handler
import (
"context"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
pb "github.com/micro/services/users/proto"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
@@ -21,6 +17,7 @@ var (
ErrDuplicateEmail = errors.BadRequest("DUPLICATE_EMAIL", "A user with this email address already exists")
ErrInvalidEmail = errors.BadRequest("INVALID_EMAIL", "The email provided is invalid")
ErrInvalidPassword = errors.BadRequest("INVALID_PASSWORD", "Password must be at least 8 characters long")
ErrMissingEmails = errors.BadRequest("MISSING_EMAILS", "One or more emails are required")
ErrMissingIDs = errors.BadRequest("MISSING_IDS", "One or more ids are required")
ErrMissingID = errors.BadRequest("MISSING_ID", "Missing ID")
ErrMissingToken = errors.BadRequest("MISSING_TOKEN", "Missing token")
@@ -65,288 +62,6 @@ type Users struct {
Time func() time.Time
}
// Create a user
func (u *Users) Create(ctx context.Context, req *pb.CreateRequest, rsp *pb.CreateResponse) error {
// validate the request
if len(req.FirstName) == 0 {
return ErrMissingFirstName
}
if len(req.LastName) == 0 {
return ErrMissingLastName
}
if len(req.Email) == 0 {
return ErrMissingEmail
}
if !isEmailValid(req.Email) {
return ErrInvalidEmail
}
if len(req.Password) < 8 {
return ErrInvalidPassword
}
// hash and salt the password using bcrypt
phash, err := hashAndSalt(req.Password)
if err != nil {
logger.Errorf("Error hasing and salting password: %v", err)
return errors.InternalServerError("HASHING_ERROR", "Error hashing password")
}
return u.DB.Transaction(func(tx *gorm.DB) error {
// write the user to the database
user := &User{
ID: uuid.New().String(),
FirstName: req.FirstName,
LastName: req.LastName,
Email: strings.ToLower(req.Email),
Password: phash,
}
err = u.DB.Create(user).Error
if err != nil && strings.Contains(err.Error(), "idx_users_email") {
return ErrDuplicateEmail
} else if err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// generate a token for the user
token := Token{
UserID: user.ID,
Key: uuid.New().String(),
ExpiresAt: u.Time().Add(time.Hour * 24 * 7),
}
if err := tx.Create(&token).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.User = user.Serialize()
rsp.Token = token.Key
return nil
})
}
// Read users using ID
func (u *Users) Read(ctx context.Context, req *pb.ReadRequest, rsp *pb.ReadResponse) error {
// validate the request
if len(req.Ids) == 0 {
return ErrMissingIDs
}
// query the database
var users []User
if err := u.DB.Model(&User{}).Where("id IN (?)", req.Ids).Find(&users).Error; err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.Users = make(map[string]*pb.User, len(users))
for _, u := range users {
rsp.Users[u.ID] = u.Serialize()
}
return nil
}
// Update a user
func (u *Users) Update(ctx context.Context, req *pb.UpdateRequest, rsp *pb.UpdateResponse) error {
// validate the request
if len(req.Id) == 0 {
return ErrMissingID
}
if req.FirstName != nil && len(req.FirstName.Value) == 0 {
return ErrMissingFirstName
}
if req.LastName != nil && len(req.LastName.Value) == 0 {
return ErrMissingLastName
}
if req.Email != nil && len(req.Email.Value) == 0 {
return ErrMissingEmail
}
if req.Email != nil && !isEmailValid(req.Email.Value) {
return ErrInvalidEmail
}
// lookup the user
var user User
if err := u.DB.Where(&User{ID: req.Id}).First(&user).Error; err == gorm.ErrRecordNotFound {
return ErrNotFound
} else if err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// assign the updated values
if req.FirstName != nil {
user.FirstName = req.FirstName.Value
}
if req.LastName != nil {
user.LastName = req.LastName.Value
}
if req.Email != nil {
user.Email = strings.ToLower(req.Email.Value)
}
// write the user to the database
err := u.DB.Save(user).Error
if err != nil && strings.Contains(err.Error(), "idx_users_email") {
return ErrDuplicateEmail
} else if err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the user
rsp.User = user.Serialize()
return nil
}
// Delete a user
func (u *Users) Delete(ctx context.Context, req *pb.DeleteRequest, rsp *pb.DeleteResponse) error {
// validate the request
if len(req.Id) == 0 {
return ErrMissingID
}
// delete the users tokens
return u.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(&Token{}, &Token{UserID: req.Id}).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// delete from the database
if err := tx.Delete(&User{}, &User{ID: req.Id}).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
return nil
})
}
// List all users
func (u *Users) List(ctx context.Context, req *pb.ListRequest, rsp *pb.ListResponse) error {
// query the database
var users []User
if err := u.DB.Model(&User{}).Find(&users).Error; err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.Users = make([]*pb.User, len(users))
for i, u := range users {
rsp.Users[i] = u.Serialize()
}
return nil
}
// Login using email and password returns the users profile and a token
func (u *Users) Login(ctx context.Context, req *pb.LoginRequest, rsp *pb.LoginResponse) error {
// validate the request
if len(req.Email) == 0 {
return ErrMissingEmail
}
if len(req.Password) == 0 {
return ErrInvalidPassword
}
return u.DB.Transaction(func(tx *gorm.DB) error {
// lookup the user
var user User
if err := tx.Where(&User{Email: req.Email}).First(&user).Error; err == gorm.ErrRecordNotFound {
return ErrNotFound
} else if err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// compare the passwords
if !passwordsMatch(user.Password, req.Password) {
return ErrIncorrectPassword
}
// generate a token for the user
token := Token{
UserID: user.ID,
Key: uuid.New().String(),
ExpiresAt: u.Time().Add(tokenTTL),
}
if err := tx.Create(&token).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.Token = token.Key
rsp.User = user.Serialize()
return nil
})
}
// Logout expires all tokens for the user
func (u *Users) Logout(ctx context.Context, req *pb.LogoutRequest, rsp *pb.LogoutResponse) error {
// validate the request
if len(req.Id) == 0 {
return ErrMissingID
}
return u.DB.Transaction(func(tx *gorm.DB) error {
// lookup the user
var user User
if err := tx.Where(&User{ID: req.Id}).Preload("Tokens").First(&user).Error; err == gorm.ErrRecordNotFound {
return ErrNotFound
} else if err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// delete the tokens
if err := tx.Delete(user.Tokens).Error; err != nil {
logger.Errorf("Error deleting from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
return nil
})
}
// Validate a token, each time a token is validated it extends its lifetime for another week
func (u *Users) Validate(ctx context.Context, req *pb.ValidateRequest, rsp *pb.ValidateResponse) error {
// validate the request
if len(req.Token) == 0 {
return ErrMissingToken
}
return u.DB.Transaction(func(tx *gorm.DB) error {
// lookup the token
var token Token
if err := tx.Where(&Token{Key: req.Token}).Preload("User").First(&token).Error; err == gorm.ErrRecordNotFound {
return ErrInvalidToken
} else if err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// ensure the token is valid
if u.Time().After(token.ExpiresAt) {
return ErrTokenExpired
}
// extend the token for another lifetime
token.ExpiresAt = u.Time().Add(tokenTTL)
if err := tx.Save(&token).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.User = token.User.Serialize()
return nil
})
}
// isEmailValid checks if the email provided passes the required structure and length.
func isEmailValid(e string) bool {
if len(e) < 3 && len(e) > 254 {

View File

@@ -1,13 +1,10 @@
package handler_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/wrapperspb"
"github.com/micro/services/users/handler"
pb "github.com/micro/services/users/proto"
@@ -35,627 +32,6 @@ func testHandler(t *testing.T) *handler.Users {
return &handler.Users{DB: db, Time: time.Now}
}
func TestCreate(t *testing.T) {
tt := []struct {
Name string
FirstName string
LastName string
Email string
Password string
Error error
}{
{
Name: "MissingFirstName",
LastName: "Doe",
Email: "john@doe.com",
Password: "password",
Error: handler.ErrMissingFirstName,
},
{
Name: "MissingLastName",
FirstName: "John",
Email: "john@doe.com",
Password: "password",
Error: handler.ErrMissingLastName,
},
{
Name: "MissingEmail",
FirstName: "John",
LastName: "Doe",
Password: "password",
Error: handler.ErrMissingEmail,
},
{
Name: "InvalidEmail",
FirstName: "John",
LastName: "Doe",
Password: "password",
Email: "foo.foo.foo",
Error: handler.ErrInvalidEmail,
},
{
Name: "InvalidPassword",
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "pwd",
Error: handler.ErrInvalidPassword,
},
}
// test the validations
h := testHandler(t)
for _, tc := range tt {
t.Run(tc.Name, func(t *testing.T) {
err := h.Create(context.TODO(), &pb.CreateRequest{
FirstName: tc.FirstName,
LastName: tc.LastName,
Email: tc.Email,
Password: tc.Password,
}, &pb.CreateResponse{})
assert.Equal(t, tc.Error, err)
})
}
t.Run("Valid", func(t *testing.T) {
var rsp pb.CreateResponse
req := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &req, &rsp)
assert.NoError(t, err)
u := rsp.User
if u == nil {
t.Fatalf("No user returned")
}
assert.NotEmpty(t, u.Id)
assert.Equal(t, req.FirstName, u.FirstName)
assert.Equal(t, req.LastName, u.LastName)
assert.Equal(t, req.Email, u.Email)
assert.NotEmpty(t, rsp.Token)
})
t.Run("DuplicateEmail", func(t *testing.T) {
var rsp pb.CreateResponse
req := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &req, &rsp)
assert.Equal(t, handler.ErrDuplicateEmail, err)
assert.Nil(t, rsp.User)
})
t.Run("DifferentEmail", func(t *testing.T) {
var rsp pb.CreateResponse
req := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "johndoe@gmail.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &req, &rsp)
assert.NoError(t, err)
u := rsp.User
if u == nil {
t.Fatalf("No user returned")
}
assert.NotEmpty(t, u.Id)
assert.Equal(t, req.FirstName, u.FirstName)
assert.Equal(t, req.LastName, u.LastName)
assert.Equal(t, req.Email, u.Email)
})
}
func TestRead(t *testing.T) {
h := testHandler(t)
t.Run("MissingIDs", func(t *testing.T) {
var rsp pb.ReadResponse
err := h.Read(context.TODO(), &pb.ReadRequest{}, &rsp)
assert.Equal(t, handler.ErrMissingIDs, err)
assert.Nil(t, rsp.Users)
})
t.Run("NotFound", func(t *testing.T) {
var rsp pb.ReadResponse
err := h.Read(context.TODO(), &pb.ReadRequest{Ids: []string{"foo"}}, &rsp)
assert.Nil(t, err)
if rsp.Users == nil {
t.Fatal("Expected the users object to not be nil")
}
assert.Nil(t, rsp.Users["foo"])
})
// create some mock data
var rsp1 pb.CreateResponse
req1 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &req1, &rsp1)
assert.NoError(t, err)
if rsp1.User == nil {
t.Fatal("No user returned")
return
}
var rsp2 pb.CreateResponse
req2 := pb.CreateRequest{
FirstName: "Apple",
LastName: "Tree",
Email: "apple@tree.com",
Password: "passwordabc",
}
err = h.Create(context.TODO(), &req2, &rsp2)
assert.NoError(t, err)
if rsp2.User == nil {
t.Fatal("No user returned")
return
}
// test the read
var rsp pb.ReadResponse
err = h.Read(context.TODO(), &pb.ReadRequest{
Ids: []string{rsp1.User.Id, rsp2.User.Id},
}, &rsp)
assert.NoError(t, err)
if rsp.Users == nil {
t.Fatal("Users not returned")
return
}
assert.NotNil(t, rsp.Users[rsp1.User.Id])
assert.NotNil(t, rsp.Users[rsp2.User.Id])
// check the users match
if u := rsp.Users[rsp1.User.Id]; u != nil {
assert.Equal(t, rsp1.User.Id, u.Id)
assert.Equal(t, rsp1.User.FirstName, u.FirstName)
assert.Equal(t, rsp1.User.LastName, u.LastName)
assert.Equal(t, rsp1.User.Email, u.Email)
}
if u := rsp.Users[rsp2.User.Id]; u != nil {
assert.Equal(t, rsp2.User.Id, u.Id)
assert.Equal(t, rsp2.User.FirstName, u.FirstName)
assert.Equal(t, rsp2.User.LastName, u.LastName)
assert.Equal(t, rsp2.User.Email, u.Email)
}
}
func TestUpdate(t *testing.T) {
h := testHandler(t)
t.Run("MissingID", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{}, &rsp)
assert.Equal(t, handler.ErrMissingID, err)
assert.Nil(t, rsp.User)
})
t.Run("NotFound", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{Id: "foo"}, &rsp)
assert.Equal(t, handler.ErrNotFound, err)
assert.Nil(t, rsp.User)
})
// create some mock data
var cRsp1 pb.CreateResponse
cReq1 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq1, &cRsp1)
assert.NoError(t, err)
if cRsp1.User == nil {
t.Fatal("No user returned")
return
}
var cRsp2 pb.CreateResponse
cReq2 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "johndoe@gmail.com",
Password: "passwordabc",
}
err = h.Create(context.TODO(), &cReq2, &cRsp2)
assert.NoError(t, err)
if cRsp2.User == nil {
t.Fatal("No user returned")
return
}
t.Run("BlankFirstName", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, FirstName: &wrapperspb.StringValue{},
}, &rsp)
assert.Equal(t, handler.ErrMissingFirstName, err)
assert.Nil(t, rsp.User)
})
t.Run("BlankLastName", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, LastName: &wrapperspb.StringValue{},
}, &rsp)
assert.Equal(t, handler.ErrMissingLastName, err)
assert.Nil(t, rsp.User)
})
t.Run("BlankLastName", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, LastName: &wrapperspb.StringValue{},
}, &rsp)
assert.Equal(t, handler.ErrMissingLastName, err)
assert.Nil(t, rsp.User)
})
t.Run("BlankEmail", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, Email: &wrapperspb.StringValue{},
}, &rsp)
assert.Equal(t, handler.ErrMissingEmail, err)
assert.Nil(t, rsp.User)
})
t.Run("InvalidEmail", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, Email: &wrapperspb.StringValue{Value: "foo.bar"},
}, &rsp)
assert.Equal(t, handler.ErrInvalidEmail, err)
assert.Nil(t, rsp.User)
})
t.Run("EmailAlreadyExists", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, Email: &wrapperspb.StringValue{Value: cRsp2.User.Email},
}, &rsp)
assert.Equal(t, handler.ErrDuplicateEmail, err)
assert.Nil(t, rsp.User)
})
t.Run("Valid", func(t *testing.T) {
uReq := pb.UpdateRequest{
Id: cRsp1.User.Id,
Email: &wrapperspb.StringValue{Value: "foobar@gmail.com"},
FirstName: &wrapperspb.StringValue{Value: "Foo"},
LastName: &wrapperspb.StringValue{Value: "Bar"},
}
var uRsp pb.UpdateResponse
err := h.Update(context.TODO(), &uReq, &uRsp)
assert.NoError(t, err)
if uRsp.User == nil {
t.Error("No user returned")
return
}
assert.Equal(t, cRsp1.User.Id, uRsp.User.Id)
assert.Equal(t, uReq.Email.Value, uRsp.User.Email)
assert.Equal(t, uReq.FirstName.Value, uRsp.User.FirstName)
assert.Equal(t, uReq.LastName.Value, uRsp.User.LastName)
})
}
func TestDelete(t *testing.T) {
h := testHandler(t)
t.Run("MissingID", func(t *testing.T) {
err := h.Delete(context.TODO(), &pb.DeleteRequest{}, &pb.DeleteResponse{})
assert.Equal(t, handler.ErrMissingID, err)
})
// create some mock data
var cRsp pb.CreateResponse
cReq := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq, &cRsp)
assert.NoError(t, err)
if cRsp.User == nil {
t.Fatal("No user returned")
return
}
t.Run("Valid", func(t *testing.T) {
err := h.Delete(context.TODO(), &pb.DeleteRequest{
Id: cRsp.User.Id,
}, &pb.DeleteResponse{})
assert.NoError(t, err)
// check it was actually deleted
var rsp pb.ReadResponse
err = h.Read(context.TODO(), &pb.ReadRequest{
Ids: []string{cRsp.User.Id},
}, &rsp)
assert.NoError(t, err)
assert.Nil(t, rsp.Users[cRsp.User.Id])
})
t.Run("Retry", func(t *testing.T) {
err := h.Delete(context.TODO(), &pb.DeleteRequest{
Id: cRsp.User.Id,
}, &pb.DeleteResponse{})
assert.NoError(t, err)
})
}
func TestList(t *testing.T) {
h := testHandler(t)
// create some mock data
var cRsp1 pb.CreateResponse
cReq1 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq1, &cRsp1)
assert.NoError(t, err)
if cRsp1.User == nil {
t.Fatal("No user returned")
return
}
var cRsp2 pb.CreateResponse
cReq2 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "johndoe@gmail.com",
Password: "passwordabc",
}
err = h.Create(context.TODO(), &cReq2, &cRsp2)
assert.NoError(t, err)
if cRsp2.User == nil {
t.Fatal("No user returned")
return
}
var rsp pb.ListResponse
err = h.List(context.TODO(), &pb.ListRequest{}, &rsp)
assert.NoError(t, err)
if rsp.Users == nil {
t.Error("No users returned")
return
}
var u1Found, u2Found bool
for _, u := range rsp.Users {
switch u.Id {
case cRsp1.User.Id:
assertUsersMatch(t, cRsp1.User, u)
u1Found = true
case cRsp2.User.Id:
assertUsersMatch(t, cRsp2.User, u)
u2Found = true
default:
t.Fatal("Unexpected user returned")
return
}
}
assert.True(t, u1Found)
assert.True(t, u2Found)
}
func TestLogin(t *testing.T) {
h := testHandler(t)
// create some mock data
var cRsp pb.CreateResponse
cReq := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq, &cRsp)
assert.NoError(t, err)
if cRsp.User == nil {
t.Fatal("No user returned")
return
}
tt := []struct {
Name string
Email string
Password string
Error error
User *pb.User
}{
{
Name: "MissingEmail",
Password: "passwordabc",
Error: handler.ErrMissingEmail,
},
{
Name: "MissingPassword",
Email: "john@doe.com",
Error: handler.ErrInvalidPassword,
},
{
Name: "UserNotFound",
Email: "foo@bar.com",
Password: "passwordabc",
Error: handler.ErrNotFound,
},
{
Name: "IncorrectPassword",
Email: "john@doe.com",
Password: "passwordabcdef",
Error: handler.ErrIncorrectPassword,
},
{
Name: "Valid",
Email: "john@doe.com",
Password: "passwordabc",
User: cRsp.User,
},
}
for _, tc := range tt {
t.Run(tc.Name, func(t *testing.T) {
var rsp pb.LoginResponse
err := h.Login(context.TODO(), &pb.LoginRequest{
Email: tc.Email, Password: tc.Password,
}, &rsp)
assert.Equal(t, tc.Error, err)
if tc.User != nil {
assertUsersMatch(t, tc.User, rsp.User)
assert.NotEmpty(t, rsp.Token)
} else {
assert.Nil(t, tc.User)
}
})
}
}
func TestLogout(t *testing.T) {
h := testHandler(t)
t.Run("MissingUserID", func(t *testing.T) {
err := h.Logout(context.TODO(), &pb.LogoutRequest{}, &pb.LogoutResponse{})
assert.Equal(t, handler.ErrMissingID, err)
})
t.Run("UserNotFound", func(t *testing.T) {
err := h.Logout(context.TODO(), &pb.LogoutRequest{Id: uuid.New().String()}, &pb.LogoutResponse{})
assert.Equal(t, handler.ErrNotFound, err)
})
t.Run("Valid", func(t *testing.T) {
// create some mock data
var cRsp pb.CreateResponse
cReq := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq, &cRsp)
assert.NoError(t, err)
if cRsp.User == nil {
t.Fatal("No user returned")
return
}
err = h.Logout(context.TODO(), &pb.LogoutRequest{Id: cRsp.User.Id}, &pb.LogoutResponse{})
assert.NoError(t, err)
err = h.Validate(context.TODO(), &pb.ValidateRequest{Token: cRsp.Token}, &pb.ValidateResponse{})
assert.Error(t, err)
})
}
func TestValidate(t *testing.T) {
h := testHandler(t)
// create some mock data
var cRsp1 pb.CreateResponse
cReq1 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq1, &cRsp1)
assert.NoError(t, err)
if cRsp1.User == nil {
t.Fatal("No user returned")
return
}
var cRsp2 pb.CreateResponse
cReq2 := pb.CreateRequest{
FirstName: "Barry",
LastName: "Doe",
Email: "barry@doe.com",
Password: "passwordabc",
}
err = h.Create(context.TODO(), &cReq2, &cRsp2)
assert.NoError(t, err)
if cRsp2.User == nil {
t.Fatal("No user returned")
return
}
tt := []struct {
Name string
Token string
Time func() time.Time
Error error
User *pb.User
}{
{
Name: "MissingToken",
Error: handler.ErrMissingToken,
},
{
Name: "InvalidToken",
Error: handler.ErrInvalidToken,
Token: uuid.New().String(),
},
{
Name: "ExpiredToken",
Error: handler.ErrTokenExpired,
Token: cRsp1.Token,
Time: func() time.Time { return time.Now().Add(time.Hour * 24 * 8) },
},
{
Name: "ValidToken",
User: cRsp2.User,
Token: cRsp2.Token,
Time: func() time.Time { return time.Now().Add(time.Hour * 24 * 3) },
},
{
Name: "RefreshedToken",
User: cRsp2.User,
Token: cRsp2.Token,
Time: func() time.Time { return time.Now().Add(time.Hour * 24 * 8) },
},
}
for _, tc := range tt {
t.Run(tc.Name, func(t *testing.T) {
if tc.Time == nil {
h.Time = time.Now
} else {
h.Time = tc.Time
}
var rsp pb.ValidateResponse
err := h.Validate(context.TODO(), &pb.ValidateRequest{Token: tc.Token}, &rsp)
assert.Equal(t, tc.Error, err)
if tc.User != nil {
assertUsersMatch(t, tc.User, rsp.User)
} else {
assert.Nil(t, tc.User)
}
})
}
}
func assertUsersMatch(t *testing.T, exp, act *pb.User) {
if act == nil {
t.Error("No user returned")

26
users/handler/list.go Normal file
View File

@@ -0,0 +1,26 @@
package handler
import (
"context"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
pb "github.com/micro/services/users/proto"
)
// List all users
func (u *Users) List(ctx context.Context, req *pb.ListRequest, rsp *pb.ListResponse) error {
// query the database
var users []User
if err := u.DB.Model(&User{}).Find(&users).Error; err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.Users = make([]*pb.User, len(users))
for i, u := range users {
rsp.Users[i] = u.Serialize()
}
return nil
}

View File

@@ -0,0 +1,67 @@
package handler_test
import (
"context"
"testing"
pb "github.com/micro/services/users/proto"
"github.com/stretchr/testify/assert"
)
func TestList(t *testing.T) {
h := testHandler(t)
// create some mock data
var cRsp1 pb.CreateResponse
cReq1 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq1, &cRsp1)
assert.NoError(t, err)
if cRsp1.User == nil {
t.Fatal("No user returned")
return
}
var cRsp2 pb.CreateResponse
cReq2 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "johndoe@gmail.com",
Password: "passwordabc",
}
err = h.Create(context.TODO(), &cReq2, &cRsp2)
assert.NoError(t, err)
if cRsp2.User == nil {
t.Fatal("No user returned")
return
}
var rsp pb.ListResponse
err = h.List(context.TODO(), &pb.ListRequest{}, &rsp)
assert.NoError(t, err)
if rsp.Users == nil {
t.Error("No users returned")
return
}
var u1Found, u2Found bool
for _, u := range rsp.Users {
switch u.Id {
case cRsp1.User.Id:
assertUsersMatch(t, cRsp1.User, u)
u1Found = true
case cRsp2.User.Id:
assertUsersMatch(t, cRsp2.User, u)
u2Found = true
default:
t.Fatal("Unexpected user returned")
return
}
}
assert.True(t, u1Found)
assert.True(t, u2Found)
}

54
users/handler/login.go Normal file
View File

@@ -0,0 +1,54 @@
package handler
import (
"context"
"github.com/google/uuid"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
pb "github.com/micro/services/users/proto"
"gorm.io/gorm"
)
// Login using email and password returns the users profile and a token
func (u *Users) Login(ctx context.Context, req *pb.LoginRequest, rsp *pb.LoginResponse) error {
// validate the request
if len(req.Email) == 0 {
return ErrMissingEmail
}
if len(req.Password) == 0 {
return ErrInvalidPassword
}
return u.DB.Transaction(func(tx *gorm.DB) error {
// lookup the user
var user User
if err := tx.Where(&User{Email: req.Email}).First(&user).Error; err == gorm.ErrRecordNotFound {
return ErrNotFound
} else if err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// compare the passwords
if !passwordsMatch(user.Password, req.Password) {
return ErrIncorrectPassword
}
// generate a token for the user
token := Token{
UserID: user.ID,
Key: uuid.New().String(),
ExpiresAt: u.Time().Add(tokenTTL),
}
if err := tx.Create(&token).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.Token = token.Key
rsp.User = user.Serialize()
return nil
})
}

View File

@@ -0,0 +1,83 @@
package handler_test
import (
"context"
"testing"
"github.com/micro/services/users/handler"
pb "github.com/micro/services/users/proto"
"github.com/stretchr/testify/assert"
)
func TestLogin(t *testing.T) {
h := testHandler(t)
// create some mock data
var cRsp pb.CreateResponse
cReq := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq, &cRsp)
assert.NoError(t, err)
if cRsp.User == nil {
t.Fatal("No user returned")
return
}
tt := []struct {
Name string
Email string
Password string
Error error
User *pb.User
}{
{
Name: "MissingEmail",
Password: "passwordabc",
Error: handler.ErrMissingEmail,
},
{
Name: "MissingPassword",
Email: "john@doe.com",
Error: handler.ErrInvalidPassword,
},
{
Name: "UserNotFound",
Email: "foo@bar.com",
Password: "passwordabc",
Error: handler.ErrNotFound,
},
{
Name: "IncorrectPassword",
Email: "john@doe.com",
Password: "passwordabcdef",
Error: handler.ErrIncorrectPassword,
},
{
Name: "Valid",
Email: "john@doe.com",
Password: "passwordabc",
User: cRsp.User,
},
}
for _, tc := range tt {
t.Run(tc.Name, func(t *testing.T) {
var rsp pb.LoginResponse
err := h.Login(context.TODO(), &pb.LoginRequest{
Email: tc.Email, Password: tc.Password,
}, &rsp)
assert.Equal(t, tc.Error, err)
if tc.User != nil {
assertUsersMatch(t, tc.User, rsp.User)
assert.NotEmpty(t, rsp.Token)
} else {
assert.Nil(t, tc.User)
}
})
}
}

37
users/handler/logout.go Normal file
View File

@@ -0,0 +1,37 @@
package handler
import (
"context"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
pb "github.com/micro/services/users/proto"
"gorm.io/gorm"
)
// Logout expires all tokens for the user
func (u *Users) Logout(ctx context.Context, req *pb.LogoutRequest, rsp *pb.LogoutResponse) error {
// validate the request
if len(req.Id) == 0 {
return ErrMissingID
}
return u.DB.Transaction(func(tx *gorm.DB) error {
// lookup the user
var user User
if err := tx.Where(&User{ID: req.Id}).Preload("Tokens").First(&user).Error; err == gorm.ErrRecordNotFound {
return ErrNotFound
} else if err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// delete the tokens
if err := tx.Delete(user.Tokens).Error; err != nil {
logger.Errorf("Error deleting from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
return nil
})
}

View File

@@ -0,0 +1,48 @@
package handler_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/micro/services/users/handler"
pb "github.com/micro/services/users/proto"
"github.com/stretchr/testify/assert"
)
func TestLogout(t *testing.T) {
h := testHandler(t)
t.Run("MissingUserID", func(t *testing.T) {
err := h.Logout(context.TODO(), &pb.LogoutRequest{}, &pb.LogoutResponse{})
assert.Equal(t, handler.ErrMissingID, err)
})
t.Run("UserNotFound", func(t *testing.T) {
err := h.Logout(context.TODO(), &pb.LogoutRequest{Id: uuid.New().String()}, &pb.LogoutResponse{})
assert.Equal(t, handler.ErrNotFound, err)
})
t.Run("Valid", func(t *testing.T) {
// create some mock data
var cRsp pb.CreateResponse
cReq := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq, &cRsp)
assert.NoError(t, err)
if cRsp.User == nil {
t.Fatal("No user returned")
return
}
err = h.Logout(context.TODO(), &pb.LogoutRequest{Id: cRsp.User.Id}, &pb.LogoutResponse{})
assert.NoError(t, err)
err = h.Validate(context.TODO(), &pb.ValidateRequest{Token: cRsp.Token}, &pb.ValidateResponse{})
assert.Error(t, err)
})
}

31
users/handler/read.go Normal file
View File

@@ -0,0 +1,31 @@
package handler
import (
"context"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
pb "github.com/micro/services/users/proto"
)
// Read users using ID
func (u *Users) Read(ctx context.Context, req *pb.ReadRequest, rsp *pb.ReadResponse) error {
// validate the request
if len(req.Ids) == 0 {
return ErrMissingIDs
}
// query the database
var users []User
if err := u.DB.Model(&User{}).Where("id IN (?)", req.Ids).Find(&users).Error; err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.Users = make(map[string]*pb.User, len(users))
for _, u := range users {
rsp.Users[u.ID] = u.Serialize()
}
return nil
}

View File

@@ -0,0 +1,36 @@
package handler
import (
"context"
"strings"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
pb "github.com/micro/services/users/proto"
)
// Read users using email
func (u *Users) ReadByEmail(ctx context.Context, req *pb.ReadByEmailRequest, rsp *pb.ReadByEmailResponse) error {
// validate the request
if len(req.Emails) == 0 {
return ErrMissingEmails
}
emails := make([]string, len(req.Emails))
for i, e := range req.Emails {
emails[i] = strings.ToLower(e)
}
// query the database
var users []User
if err := u.DB.Model(&User{}).Where("lower(email) IN (?)", emails).Find(&users).Error; err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.Users = make(map[string]*pb.User, len(users))
for _, u := range users {
rsp.Users[u.Email] = u.Serialize()
}
return nil
}

View File

@@ -0,0 +1,89 @@
package handler_test
import (
"context"
"strings"
"testing"
"github.com/micro/services/users/handler"
pb "github.com/micro/services/users/proto"
"github.com/stretchr/testify/assert"
)
func TestReadByEmail(t *testing.T) {
h := testHandler(t)
t.Run("MissingEmails", func(t *testing.T) {
var rsp pb.ReadByEmailResponse
err := h.ReadByEmail(context.TODO(), &pb.ReadByEmailRequest{}, &rsp)
assert.Equal(t, handler.ErrMissingEmails, err)
assert.Nil(t, rsp.Users)
})
t.Run("NotFound", func(t *testing.T) {
var rsp pb.ReadByEmailResponse
err := h.ReadByEmail(context.TODO(), &pb.ReadByEmailRequest{Emails: []string{"foo"}}, &rsp)
assert.Nil(t, err)
if rsp.Users == nil {
t.Fatal("Expected the users object to not be nil")
}
assert.Nil(t, rsp.Users["foo"])
})
// create some mock data
var rsp1 pb.CreateResponse
req1 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &req1, &rsp1)
assert.NoError(t, err)
if rsp1.User == nil {
t.Fatal("No user returned")
return
}
var rsp2 pb.CreateResponse
req2 := pb.CreateRequest{
FirstName: "Apple",
LastName: "Tree",
Email: "apple@tree.com",
Password: "passwordabc",
}
err = h.Create(context.TODO(), &req2, &rsp2)
assert.NoError(t, err)
if rsp2.User == nil {
t.Fatal("No user returned")
return
}
// test the read
var rsp pb.ReadByEmailResponse
err = h.ReadByEmail(context.TODO(), &pb.ReadByEmailRequest{
Emails: []string{rsp1.User.Email, strings.ToUpper(rsp2.User.Email)},
}, &rsp)
assert.NoError(t, err)
if rsp.Users == nil {
t.Fatal("Users not returned")
return
}
assert.NotNil(t, rsp.Users[rsp1.User.Email])
assert.NotNil(t, rsp.Users[rsp2.User.Email])
// check the users match
if u := rsp.Users[rsp1.User.Email]; u != nil {
assert.Equal(t, rsp1.User.Id, u.Id)
assert.Equal(t, rsp1.User.FirstName, u.FirstName)
assert.Equal(t, rsp1.User.LastName, u.LastName)
assert.Equal(t, rsp1.User.Email, u.Email)
}
if u := rsp.Users[rsp2.User.Email]; u != nil {
assert.Equal(t, rsp2.User.Id, u.Id)
assert.Equal(t, rsp2.User.FirstName, u.FirstName)
assert.Equal(t, rsp2.User.LastName, u.LastName)
assert.Equal(t, rsp2.User.Email, u.Email)
}
}

View File

@@ -0,0 +1,88 @@
package handler_test
import (
"context"
"testing"
"github.com/micro/services/users/handler"
pb "github.com/micro/services/users/proto"
"github.com/stretchr/testify/assert"
)
func TestRead(t *testing.T) {
h := testHandler(t)
t.Run("MissingIDs", func(t *testing.T) {
var rsp pb.ReadResponse
err := h.Read(context.TODO(), &pb.ReadRequest{}, &rsp)
assert.Equal(t, handler.ErrMissingIDs, err)
assert.Nil(t, rsp.Users)
})
t.Run("NotFound", func(t *testing.T) {
var rsp pb.ReadResponse
err := h.Read(context.TODO(), &pb.ReadRequest{Ids: []string{"foo"}}, &rsp)
assert.Nil(t, err)
if rsp.Users == nil {
t.Fatal("Expected the users object to not be nil")
}
assert.Nil(t, rsp.Users["foo"])
})
// create some mock data
var rsp1 pb.CreateResponse
req1 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &req1, &rsp1)
assert.NoError(t, err)
if rsp1.User == nil {
t.Fatal("No user returned")
return
}
var rsp2 pb.CreateResponse
req2 := pb.CreateRequest{
FirstName: "Apple",
LastName: "Tree",
Email: "apple@tree.com",
Password: "passwordabc",
}
err = h.Create(context.TODO(), &req2, &rsp2)
assert.NoError(t, err)
if rsp2.User == nil {
t.Fatal("No user returned")
return
}
// test the read
var rsp pb.ReadResponse
err = h.Read(context.TODO(), &pb.ReadRequest{
Ids: []string{rsp1.User.Id, rsp2.User.Id},
}, &rsp)
assert.NoError(t, err)
if rsp.Users == nil {
t.Fatal("Users not returned")
return
}
assert.NotNil(t, rsp.Users[rsp1.User.Id])
assert.NotNil(t, rsp.Users[rsp2.User.Id])
// check the users match
if u := rsp.Users[rsp1.User.Id]; u != nil {
assert.Equal(t, rsp1.User.Id, u.Id)
assert.Equal(t, rsp1.User.FirstName, u.FirstName)
assert.Equal(t, rsp1.User.LastName, u.LastName)
assert.Equal(t, rsp1.User.Email, u.Email)
}
if u := rsp.Users[rsp2.User.Id]; u != nil {
assert.Equal(t, rsp2.User.Id, u.Id)
assert.Equal(t, rsp2.User.FirstName, u.FirstName)
assert.Equal(t, rsp2.User.LastName, u.LastName)
assert.Equal(t, rsp2.User.Email, u.Email)
}
}

75
users/handler/update.go Normal file
View File

@@ -0,0 +1,75 @@
package handler
import (
"context"
"strings"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
pb "github.com/micro/services/users/proto"
"gorm.io/gorm"
)
// Update a user
func (u *Users) Update(ctx context.Context, req *pb.UpdateRequest, rsp *pb.UpdateResponse) error {
// validate the request
if len(req.Id) == 0 {
return ErrMissingID
}
if req.FirstName != nil && len(req.FirstName.Value) == 0 {
return ErrMissingFirstName
}
if req.LastName != nil && len(req.LastName.Value) == 0 {
return ErrMissingLastName
}
if req.Email != nil && len(req.Email.Value) == 0 {
return ErrMissingEmail
}
if req.Email != nil && !isEmailValid(req.Email.Value) {
return ErrInvalidEmail
}
if req.Password != nil && len(req.Password.Value) < 8 {
return ErrInvalidEmail
}
// lookup the user
var user User
if err := u.DB.Where(&User{ID: req.Id}).First(&user).Error; err == gorm.ErrRecordNotFound {
return ErrNotFound
} else if err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// assign the updated values
if req.FirstName != nil {
user.FirstName = req.FirstName.Value
}
if req.LastName != nil {
user.LastName = req.LastName.Value
}
if req.Email != nil {
user.Email = strings.ToLower(req.Email.Value)
}
if req.Password != nil {
p, err := hashAndSalt(req.Password.Value)
if err != nil {
logger.Errorf("Error hasing and salting password: %v", err)
return errors.InternalServerError("HASHING_ERROR", "Error hashing password")
}
user.Password = p
}
// write the user to the database
err := u.DB.Save(user).Error
if err != nil && strings.Contains(err.Error(), "idx_users_email") {
return ErrDuplicateEmail
} else if err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the user
rsp.User = user.Serialize()
return nil
}

View File

@@ -0,0 +1,148 @@
package handler_test
import (
"context"
"testing"
"github.com/micro/services/users/handler"
pb "github.com/micro/services/users/proto"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/wrapperspb"
)
func TestUpdate(t *testing.T) {
h := testHandler(t)
t.Run("MissingID", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{}, &rsp)
assert.Equal(t, handler.ErrMissingID, err)
assert.Nil(t, rsp.User)
})
t.Run("NotFound", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{Id: "foo"}, &rsp)
assert.Equal(t, handler.ErrNotFound, err)
assert.Nil(t, rsp.User)
})
// create some mock data
var cRsp1 pb.CreateResponse
cReq1 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq1, &cRsp1)
assert.NoError(t, err)
if cRsp1.User == nil {
t.Fatal("No user returned")
return
}
var cRsp2 pb.CreateResponse
cReq2 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "johndoe@gmail.com",
Password: "passwordabc",
}
err = h.Create(context.TODO(), &cReq2, &cRsp2)
assert.NoError(t, err)
if cRsp2.User == nil {
t.Fatal("No user returned")
return
}
t.Run("BlankFirstName", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, FirstName: &wrapperspb.StringValue{},
}, &rsp)
assert.Equal(t, handler.ErrMissingFirstName, err)
assert.Nil(t, rsp.User)
})
t.Run("BlankLastName", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, LastName: &wrapperspb.StringValue{},
}, &rsp)
assert.Equal(t, handler.ErrMissingLastName, err)
assert.Nil(t, rsp.User)
})
t.Run("BlankLastName", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, LastName: &wrapperspb.StringValue{},
}, &rsp)
assert.Equal(t, handler.ErrMissingLastName, err)
assert.Nil(t, rsp.User)
})
t.Run("BlankEmail", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, Email: &wrapperspb.StringValue{},
}, &rsp)
assert.Equal(t, handler.ErrMissingEmail, err)
assert.Nil(t, rsp.User)
})
t.Run("InvalidEmail", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, Email: &wrapperspb.StringValue{Value: "foo.bar"},
}, &rsp)
assert.Equal(t, handler.ErrInvalidEmail, err)
assert.Nil(t, rsp.User)
})
t.Run("EmailAlreadyExists", func(t *testing.T) {
var rsp pb.UpdateResponse
err := h.Update(context.TODO(), &pb.UpdateRequest{
Id: cRsp1.User.Id, Email: &wrapperspb.StringValue{Value: cRsp2.User.Email},
}, &rsp)
assert.Equal(t, handler.ErrDuplicateEmail, err)
assert.Nil(t, rsp.User)
})
t.Run("Valid", func(t *testing.T) {
uReq := pb.UpdateRequest{
Id: cRsp1.User.Id,
Email: &wrapperspb.StringValue{Value: "foobar@gmail.com"},
FirstName: &wrapperspb.StringValue{Value: "Foo"},
LastName: &wrapperspb.StringValue{Value: "Bar"},
}
var uRsp pb.UpdateResponse
err := h.Update(context.TODO(), &uReq, &uRsp)
assert.NoError(t, err)
if uRsp.User == nil {
t.Error("No user returned")
return
}
assert.Equal(t, cRsp1.User.Id, uRsp.User.Id)
assert.Equal(t, uReq.Email.Value, uRsp.User.Email)
assert.Equal(t, uReq.FirstName.Value, uRsp.User.FirstName)
assert.Equal(t, uReq.LastName.Value, uRsp.User.LastName)
})
t.Run("UpdatePassword", func(t *testing.T) {
uReq := pb.UpdateRequest{
Id: cRsp2.User.Id,
Password: &wrapperspb.StringValue{Value: "helloworld"},
}
err := h.Update(context.TODO(), &uReq, &pb.UpdateResponse{})
assert.NoError(t, err)
lReq := pb.LoginRequest{
Email: cRsp2.User.Email,
Password: "helloworld",
}
err = h.Login(context.TODO(), &lReq, &pb.LoginResponse{})
assert.NoError(t, err)
})
}

45
users/handler/validate.go Normal file
View File

@@ -0,0 +1,45 @@
package handler
import (
"context"
"github.com/micro/micro/v3/service/errors"
"github.com/micro/micro/v3/service/logger"
pb "github.com/micro/services/users/proto"
"gorm.io/gorm"
)
// Validate a token, each time a token is validated it extends its lifetime for another week
func (u *Users) Validate(ctx context.Context, req *pb.ValidateRequest, rsp *pb.ValidateResponse) error {
// validate the request
if len(req.Token) == 0 {
return ErrMissingToken
}
return u.DB.Transaction(func(tx *gorm.DB) error {
// lookup the token
var token Token
if err := tx.Where(&Token{Key: req.Token}).Preload("User").First(&token).Error; err == gorm.ErrRecordNotFound {
return ErrInvalidToken
} else if err != nil {
logger.Errorf("Error reading from the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// ensure the token is valid
if u.Time().After(token.ExpiresAt) {
return ErrTokenExpired
}
// extend the token for another lifetime
token.ExpiresAt = u.Time().Add(tokenTTL)
if err := tx.Save(&token).Error; err != nil {
logger.Errorf("Error writing to the database: %v", err)
return errors.InternalServerError("DATABASE_ERROR", "Error connecting to the database")
}
// serialize the response
rsp.User = token.User.Serialize()
return nil
})
}

View File

@@ -0,0 +1,101 @@
package handler_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/micro/services/users/handler"
pb "github.com/micro/services/users/proto"
"github.com/stretchr/testify/assert"
)
func TestValidate(t *testing.T) {
h := testHandler(t)
// create some mock data
var cRsp1 pb.CreateResponse
cReq1 := pb.CreateRequest{
FirstName: "John",
LastName: "Doe",
Email: "john@doe.com",
Password: "passwordabc",
}
err := h.Create(context.TODO(), &cReq1, &cRsp1)
assert.NoError(t, err)
if cRsp1.User == nil {
t.Fatal("No user returned")
return
}
var cRsp2 pb.CreateResponse
cReq2 := pb.CreateRequest{
FirstName: "Barry",
LastName: "Doe",
Email: "barry@doe.com",
Password: "passwordabc",
}
err = h.Create(context.TODO(), &cReq2, &cRsp2)
assert.NoError(t, err)
if cRsp2.User == nil {
t.Fatal("No user returned")
return
}
tt := []struct {
Name string
Token string
Time func() time.Time
Error error
User *pb.User
}{
{
Name: "MissingToken",
Error: handler.ErrMissingToken,
},
{
Name: "InvalidToken",
Error: handler.ErrInvalidToken,
Token: uuid.New().String(),
},
{
Name: "ExpiredToken",
Error: handler.ErrTokenExpired,
Token: cRsp1.Token,
Time: func() time.Time { return time.Now().Add(time.Hour * 24 * 8) },
},
{
Name: "ValidToken",
User: cRsp2.User,
Token: cRsp2.Token,
Time: func() time.Time { return time.Now().Add(time.Hour * 24 * 3) },
},
{
Name: "RefreshedToken",
User: cRsp2.User,
Token: cRsp2.Token,
Time: func() time.Time { return time.Now().Add(time.Hour * 24 * 8) },
},
}
for _, tc := range tt {
t.Run(tc.Name, func(t *testing.T) {
if tc.Time == nil {
h.Time = time.Now
} else {
h.Time = tc.Time
}
var rsp pb.ValidateResponse
err := h.Validate(context.TODO(), &pb.ValidateRequest{Token: tc.Token}, &rsp)
assert.Equal(t, tc.Error, err)
if tc.User != nil {
assertUsersMatch(t, tc.User, rsp.User)
} else {
assert.Nil(t, tc.User)
}
})
}
}