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