246 lines
8.5 KiB
Go
246 lines
8.5 KiB
Go
package handler
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/gin-gonic/gin"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/stretchr/testify/assert"
|
||
|
|
"github.com/stretchr/testify/require"
|
||
|
|
"golang.org/x/crypto/bcrypt"
|
||
|
|
|
||
|
|
"apskel-pos-be/internal/entities"
|
||
|
|
applogger "apskel-pos-be/internal/logger"
|
||
|
|
"apskel-pos-be/internal/processor"
|
||
|
|
"apskel-pos-be/internal/service"
|
||
|
|
"apskel-pos-be/internal/validator"
|
||
|
|
)
|
||
|
|
|
||
|
|
// customerAuthRepoFake holds customers by phone number. Only the login lookup is used.
|
||
|
|
type customerAuthRepoFake struct {
|
||
|
|
customers map[string]*entities.Customer
|
||
|
|
err error
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *customerAuthRepoFake) GetCustomerByPhoneNumber(_ context.Context, phone string) (*entities.Customer, error) {
|
||
|
|
if f.err != nil {
|
||
|
|
return nil, f.err
|
||
|
|
}
|
||
|
|
return f.customers[phone], nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *customerAuthRepoFake) GetCustomerByID(context.Context, string) (*entities.Customer, error) {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
func (f *customerAuthRepoFake) CreateCustomer(context.Context, *entities.Customer) error { return nil }
|
||
|
|
func (f *customerAuthRepoFake) UpdateCustomer(context.Context, *entities.Customer) error { return nil }
|
||
|
|
func (f *customerAuthRepoFake) CheckPhoneNumberExists(context.Context, string) (bool, error) {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
func (f *customerAuthRepoFake) SetCustomerPassword(context.Context, string, string) error {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
func (f *customerAuthRepoFake) OrganizationExists(context.Context, uuid.UUID) (bool, error) {
|
||
|
|
return false, nil
|
||
|
|
}
|
||
|
|
func (f *customerAuthRepoFake) OrganizationIDs(context.Context, int) ([]uuid.UUID, error) {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// loginAttemptsFake counts attempts per phone number in one window that never ends.
|
||
|
|
type loginAttemptsFake struct {
|
||
|
|
counts map[string]int64
|
||
|
|
err error
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *loginAttemptsFake) Hit(_ context.Context, phone string, window time.Duration) (int64, time.Duration, error) {
|
||
|
|
if f.err != nil {
|
||
|
|
return 0, 0, f.err
|
||
|
|
}
|
||
|
|
f.counts[phone]++
|
||
|
|
return f.counts[phone], window, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *loginAttemptsFake) Reset(_ context.Context, phone string) error {
|
||
|
|
delete(f.counts, phone)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const (
|
||
|
|
loginTestPassword = "rahasia123"
|
||
|
|
loginTestRegistered = "6281234561234"
|
||
|
|
loginTestUnfinished = "6281234569999"
|
||
|
|
)
|
||
|
|
|
||
|
|
type loginTest struct {
|
||
|
|
repo *customerAuthRepoFake
|
||
|
|
attempts *loginAttemptsFake
|
||
|
|
router *gin.Engine
|
||
|
|
}
|
||
|
|
|
||
|
|
func newLoginTest(t *testing.T) *loginTest {
|
||
|
|
t.Helper()
|
||
|
|
applogger.Setup("fatal", "json")
|
||
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(loginTestPassword), bcrypt.MinCost)
|
||
|
|
require.NoError(t, err)
|
||
|
|
hashStr := string(hash)
|
||
|
|
registered, unfinished := loginTestRegistered, loginTestUnfinished
|
||
|
|
birth := time.Date(2000, 1, 31, 0, 0, 0, 0, time.UTC)
|
||
|
|
lt := &loginTest{
|
||
|
|
repo: &customerAuthRepoFake{customers: map[string]*entities.Customer{
|
||
|
|
registered: {ID: uuid.New(), Name: "Budi", PhoneNumber: ®istered, BirthDate: &birth, PasswordHash: &hashStr},
|
||
|
|
unfinished: {ID: uuid.New(), Name: "Sari", PhoneNumber: &unfinished},
|
||
|
|
}},
|
||
|
|
attempts: &loginAttemptsFake{counts: map[string]int64{}},
|
||
|
|
}
|
||
|
|
h := NewCustomerAuthHandler(
|
||
|
|
service.NewCustomerAuthService(processor.NewCustomerAuthProcessor(lt.repo, lt.attempts, nil, nil, "test", 60)),
|
||
|
|
validator.NewCustomerAuthValidator(),
|
||
|
|
)
|
||
|
|
gin.SetMode(gin.TestMode)
|
||
|
|
lt.router = gin.New()
|
||
|
|
lt.router.POST("/customer-auth/login", h.Login)
|
||
|
|
return lt
|
||
|
|
}
|
||
|
|
|
||
|
|
func (lt *loginTest) login(t *testing.T, phone, password string) (int, map[string]any) {
|
||
|
|
t.Helper()
|
||
|
|
body, _ := json.Marshal(map[string]string{"phone_number": phone, "password": password})
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
lt.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/customer-auth/login", bytes.NewReader(body)))
|
||
|
|
var out map[string]any
|
||
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
|
||
|
|
return rec.Code, out
|
||
|
|
}
|
||
|
|
|
||
|
|
func firstLoginError(t *testing.T, out map[string]any) map[string]any {
|
||
|
|
t.Helper()
|
||
|
|
errs, ok := out["errors"].([]any)
|
||
|
|
require.True(t, ok, "errors: %v", out)
|
||
|
|
require.NotEmpty(t, errs)
|
||
|
|
return errs[0].(map[string]any)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCustomerLoginRefusalsAreValidationErrors(t *testing.T) {
|
||
|
|
lt := newLoginTest(t)
|
||
|
|
|
||
|
|
for name, tc := range map[string]struct{ phone, password, cause string }{
|
||
|
|
"wrong password": {loginTestRegistered, "salah", "invalid phone number or password"},
|
||
|
|
"unknown phone": {"6280000000000", loginTestPassword, "invalid phone number or password"},
|
||
|
|
"registration unfinished": {loginTestUnfinished, loginTestPassword, "customer not properly registered"},
|
||
|
|
} {
|
||
|
|
t.Run(name, func(t *testing.T) {
|
||
|
|
status, out := lt.login(t, tc.phone, tc.password)
|
||
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
||
|
|
e := firstLoginError(t, out)
|
||
|
|
assert.Equal(t, "304", e["code"])
|
||
|
|
assert.Equal(t, "customer_auth_service", e["entity"])
|
||
|
|
assert.Equal(t, tc.cause, e["cause"])
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
t.Run("right password", func(t *testing.T) {
|
||
|
|
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
||
|
|
require.Equal(t, http.StatusOK, status, "%v", out)
|
||
|
|
inner := out["data"].(map[string]any)["data"].(map[string]any)
|
||
|
|
assert.NotEmpty(t, inner["access_token"])
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("the number written another way finds the same customer", func(t *testing.T) {
|
||
|
|
for _, phone := range []string{"081234561234", "+62 812-3456-1234", "81234561234"} {
|
||
|
|
status, out := lt.login(t, phone, loginTestPassword)
|
||
|
|
require.Equal(t, http.StatusOK, status, "%s: %v", phone, out)
|
||
|
|
user := out["data"].(map[string]any)["data"].(map[string]any)["user"].(map[string]any)
|
||
|
|
assert.Equal(t, loginTestRegistered, user["phone_number"], phone)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("a number that is not an Indonesian mobile number", func(t *testing.T) {
|
||
|
|
status, out := lt.login(t, "021-1234567", loginTestPassword)
|
||
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
||
|
|
e := firstLoginError(t, out)
|
||
|
|
assert.Equal(t, "304", e["code"])
|
||
|
|
assert.Equal(t, "invalid phone number format", e["cause"])
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("a failing lookup is still a server error", func(t *testing.T) {
|
||
|
|
lt.repo.err = errors.New("connection refused")
|
||
|
|
defer func() { lt.repo.err = nil }()
|
||
|
|
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
||
|
|
assert.Equal(t, http.StatusInternalServerError, status)
|
||
|
|
assert.Equal(t, "900", firstLoginError(t, out)["code"])
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCustomerLoginLocksAfterTooManyAttempts(t *testing.T) {
|
||
|
|
t.Run("the sixth attempt is refused, even with the right password", func(t *testing.T) {
|
||
|
|
lt := newLoginTest(t)
|
||
|
|
for i := 0; i < 5; i++ {
|
||
|
|
status, _ := lt.login(t, loginTestRegistered, "salah")
|
||
|
|
require.Equal(t, http.StatusBadRequest, status, "attempt %d", i+1)
|
||
|
|
}
|
||
|
|
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
||
|
|
assert.Equal(t, http.StatusTooManyRequests, status)
|
||
|
|
e := firstLoginError(t, out)
|
||
|
|
assert.Equal(t, "429", e["code"])
|
||
|
|
assert.Equal(t, "customer_auth_service", e["entity"])
|
||
|
|
data, ok := out["data"].(map[string]any)
|
||
|
|
require.True(t, ok, "data: %v", out)
|
||
|
|
until, err := time.Parse(time.RFC3339, data["locked_until"].(string))
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.WithinDuration(t, time.Now().Add(15*time.Minute), until, time.Minute)
|
||
|
|
|
||
|
|
// Another number is not affected.
|
||
|
|
status, _ = lt.login(t, loginTestUnfinished, loginTestPassword)
|
||
|
|
assert.Equal(t, http.StatusBadRequest, status)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("writing the number another way counts toward the same limit", func(t *testing.T) {
|
||
|
|
lt := newLoginTest(t)
|
||
|
|
for _, phone := range []string{"081234561234", "+6281234561234", "81234561234", "0812-3456-1234", loginTestRegistered} {
|
||
|
|
lt.login(t, phone, "salah")
|
||
|
|
}
|
||
|
|
status, _ := lt.login(t, "081234561234", loginTestPassword)
|
||
|
|
assert.Equal(t, http.StatusTooManyRequests, status)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("unknown numbers lock the same way", func(t *testing.T) {
|
||
|
|
lt := newLoginTest(t)
|
||
|
|
for i := 0; i < 5; i++ {
|
||
|
|
lt.login(t, "6280000000000", "salah")
|
||
|
|
}
|
||
|
|
status, _ := lt.login(t, "6280000000000", "salah")
|
||
|
|
assert.Equal(t, http.StatusTooManyRequests, status)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("a successful login starts the count again", func(t *testing.T) {
|
||
|
|
lt := newLoginTest(t)
|
||
|
|
for i := 0; i < 4; i++ {
|
||
|
|
lt.login(t, loginTestRegistered, "salah")
|
||
|
|
}
|
||
|
|
status, _ := lt.login(t, loginTestRegistered, loginTestPassword)
|
||
|
|
require.Equal(t, http.StatusOK, status)
|
||
|
|
for i := 0; i < 5; i++ {
|
||
|
|
status, _ := lt.login(t, loginTestRegistered, "salah")
|
||
|
|
require.Equal(t, http.StatusBadRequest, status, "attempt %d after the login", i+1)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("logins go on when the counter is down", func(t *testing.T) {
|
||
|
|
lt := newLoginTest(t)
|
||
|
|
lt.attempts.err = errors.New("redis: connection refused")
|
||
|
|
for i := 0; i < 6; i++ {
|
||
|
|
lt.login(t, loginTestRegistered, "salah")
|
||
|
|
}
|
||
|
|
status, _ := lt.login(t, loginTestRegistered, loginTestPassword)
|
||
|
|
assert.Equal(t, http.StatusOK, status)
|
||
|
|
})
|
||
|
|
}
|