package processor import ( "context" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) func TestCheckNewPin(t *testing.T) { birth := time.Date(1990, 3, 14, 0, 0, 0, 0, time.UTC) for _, ok := range []string{"482913", "019283", "135790", "112233"} { assert.NoError(t, checkNewPin(ok, ok, &birth), ok) } for name, c := range map[string][2]string{ "too short": {"12345", "12345"}, "too long": {"1234567", "1234567"}, "not digits": {"12a456", "12a456"}, "confirmation": {"482913", "482914"}, "one digit": {"111111", "111111"}, "zeros": {"000000", "000000"}, "run up": {"123456", "123456"}, "run up from 4": {"456789", "456789"}, "run down": {"654321", "654321"}, "run down from 9": {"987654", "987654"}, "birth date DDMMYY": {"140390", "140390"}, "birth date YYMMDD": {"900314", "900314"}, } { err := checkNewPin(c[0], c[1], &birth) assert.ErrorIs(t, err, ErrInvalidPinInput, name) assert.NotContains(t, err.Error(), c[0], "%s: the message must not echo the PIN", name) } // Without a birth date only the other rules apply. assert.NoError(t, checkNewPin("140390", "140390", nil)) } // pinRepoFake holds one customer's PIN state, with the lock rules of RecordFailure. type pinRepoFake struct { repository.CustomerPinRepository state repository.CustomerPinState } func (f *pinRepoFake) GetState(context.Context, uuid.UUID) (*repository.CustomerPinState, error) { s := f.state return &s, nil } func (f *pinRepoFake) RecordFailure(_ context.Context, _ uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) { if f.state.LockedUntil != nil && !f.state.LockedUntil.After(now) { f.state.FailedAttempts, f.state.LockedUntil = 1, nil } else { f.state.FailedAttempts++ if f.state.FailedAttempts >= maxAttempts { f.state.LockedUntil = &lockUntil } } return f.state.FailedAttempts, f.state.LockedUntil, nil } func (f *pinRepoFake) ClearFailures(context.Context, uuid.UUID) error { f.state.FailedAttempts, f.state.LockedUntil = 0, nil return nil } func (f *pinRepoFake) InsertEvent(context.Context, repository.CustomerSecurityEvent) error { return nil } func TestCustomerPin_LockIsPushedThroughFCM(t *testing.T) { customer := uuid.New() hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost) require.NoError(t, err) h := string(hash) repo := &pinRepoFake{state: repository.CustomerPinState{CustomerID: customer, PinHash: &h}} notifier := ¬ifierFake{} p := NewCustomerPinProcessor(repo, nil, notifier) now := time.Date(2026, 9, 30, 3, 0, 0, 0, time.UTC) p.now = func() time.Time { return now } ctx := context.Background() for i := 0; i < 4; i++ { _ = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{}) } assert.Empty(t, notifier.pushes[customer], "no push before the PIN locks") err = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{}) var pinErr *PinError require.ErrorAs(t, err, &pinErr) assert.Equal(t, PinErrLocked, pinErr.Code) // Attempts while locked do not push again. _ = p.VerifyPin(ctx, customer, "482913", PinActionPay, models.CustomerPinRequestInfo{}) require.Len(t, notifier.pushes[customer], 1) push := notifier.pushes[customer][0] assert.Equal(t, "PIN terkunci", push.title) assert.Equal(t, "PIN EnakPoint kamu terkunci sampai 30 Sep 2026 10:30 WIB karena salah dimasukkan 5 kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", push.body) assert.Equal(t, map[string]string{"type": NotificationTypePinLocked, "locked_until": "2026-09-30T03:30:00Z"}, push.data) } // EG-503: redeeming a voucher follows the same PIN rules: five wrong attempts lock // it for 30 minutes, and a locked PIN is refused even when right. A transfer hold // after a reset does not apply to it. func TestCustomerPin_RedeemFollowsTheLockRules(t *testing.T) { customer := uuid.New() hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost) require.NoError(t, err) h := string(hash) now := time.Date(2026, 10, 7, 3, 0, 0, 0, time.UTC) held := now.Add(24 * time.Hour) repo := &pinRepoFake{state: repository.CustomerPinState{CustomerID: customer, PinHash: &h, TransferBlockedUntil: &held}} p := NewCustomerPinProcessor(repo, nil, ¬ifierFake{}) p.now = func() time.Time { return now } ctx := context.Background() require.NoError(t, p.VerifyPin(ctx, customer, "482913", PinActionRedeem, models.CustomerPinRequestInfo{}), "not held like a transfer") var pinErr *PinError for i := 1; i <= 4; i++ { err := p.VerifyPin(ctx, customer, "000000", PinActionRedeem, models.CustomerPinRequestInfo{}) require.ErrorAs(t, err, &pinErr) assert.Equal(t, PinErrInvalid, pinErr.Code) assert.Equal(t, 5-i, pinErr.RemainingAttempts) } err = p.VerifyPin(ctx, customer, "000000", PinActionRedeem, models.CustomerPinRequestInfo{}) require.ErrorAs(t, err, &pinErr) assert.Equal(t, PinErrLocked, pinErr.Code) assert.Equal(t, now.Add(30*time.Minute), *pinErr.Until) err = p.VerifyPin(ctx, customer, "482913", PinActionRedeem, models.CustomerPinRequestInfo{}) require.ErrorAs(t, err, &pinErr) assert.Equal(t, PinErrLocked, pinErr.Code, "locked even with the right PIN") }