A customer whose PIN locks after five wrong attempts is now told by a push through FCM instead of WhatsApp (docs/prd-point-coin.md F11), to every device registered at /customer/devices. The push is titled "PIN terkunci", says until when it is locked, and carries type PIN_LOCKED and locked_until in its data so the app can offer the PIN reset. As before, only the attempt that reached the limit sends it, and a failure to send is logged without affecting the lock. OtpProcessor.SendWhatsAppMessage was only there for this alert and is removed; OTPs still go out by WhatsApp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
11 KiB
Go
258 lines
11 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"apskel-pos-be/internal/entities"
|
|
"apskel-pos-be/internal/models"
|
|
"apskel-pos-be/internal/repository"
|
|
)
|
|
|
|
// otpFake keeps OTP sessions in memory with the checks the real one makes.
|
|
type otpFake struct {
|
|
mu sync.Mutex
|
|
sessions map[string]*entities.OtpSession
|
|
sent []string
|
|
}
|
|
|
|
func (f *otpFake) CanResendOtp(context.Context, string, string) (bool, int, error) {
|
|
return true, 0, nil
|
|
}
|
|
|
|
func (f *otpFake) CreateOtpSession(_ context.Context, phone, purpose string) (*entities.OtpSession, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
s := &entities.OtpSession{Token: uuid.NewString(), Code: "246810", PhoneNumber: phone, Purpose: purpose, ExpiresAt: time.Now().Add(5 * time.Minute)}
|
|
f.sessions[s.Token] = s
|
|
return s, nil
|
|
}
|
|
|
|
func (f *otpFake) SendOtpViaWhatsApp(phone, code, purpose string) error {
|
|
f.sent = append(f.sent, purpose)
|
|
return nil
|
|
}
|
|
|
|
func (f *otpFake) ValidateOtpSession(_ context.Context, token, code string) (*entities.OtpSession, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
s := f.sessions[token]
|
|
if s == nil || s.IsUsed || s.Code != code {
|
|
return nil, errors.New("invalid OTP")
|
|
}
|
|
s.IsUsed = true
|
|
return s, nil
|
|
}
|
|
|
|
// issue creates a session as if it had been sent, for any purpose and number.
|
|
func (f *otpFake) issue(phone, purpose string) *entities.OtpSession {
|
|
s, _ := f.CreateOtpSession(context.Background(), phone, purpose)
|
|
return s
|
|
}
|
|
|
|
type alerterFake struct {
|
|
mu sync.Mutex
|
|
messages []string
|
|
}
|
|
|
|
func (f *alerterFake) Notify(_ context.Context, _ uuid.UUID, _, body string, _ map[string]string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.messages = append(f.messages, body)
|
|
return nil
|
|
}
|
|
|
|
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
|
// internal/repository/wallet_repository_test.go.
|
|
func TestCustomerPin_AgainstPostgres(t *testing.T) {
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL not set")
|
|
}
|
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
require.NoError(t, err)
|
|
ctx := context.Background()
|
|
|
|
org, otherOrg, customer, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
|
phone := "0812" + customer.String()[:8]
|
|
exec := func(q string, args ...any) {
|
|
t.Helper()
|
|
require.NoError(t, db.Exec(q, args...).Error)
|
|
}
|
|
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pin test', 'basic'), (?, 'other', 'basic')`, org, otherOrg)
|
|
exec(`INSERT INTO customers (id, organization_id, name, phone_number, birth_date) VALUES (?, ?, 'Budi', ?, '1990-03-14')`, customer, org, phone)
|
|
t.Cleanup(func() {
|
|
db.Exec(`DELETE FROM customer_security_events WHERE customer_id = ?`, customer)
|
|
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
|
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
|
})
|
|
|
|
otp := &otpFake{sessions: map[string]*entities.OtpSession{}}
|
|
alerts := &alerterFake{}
|
|
p := NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), otp, alerts)
|
|
p.cost = bcrypt.MinCost
|
|
clock := time.Now()
|
|
var clockMu sync.Mutex
|
|
p.now = func() time.Time { clockMu.Lock(); defer clockMu.Unlock(); return clock }
|
|
advance := func(d time.Duration) { clockMu.Lock(); clock = clock.Add(d); clockMu.Unlock() }
|
|
info := models.CustomerPinRequestInfo{IPAddress: "10.0.0.7", UserAgent: "EnakApp/2.0"}
|
|
const pin, newPin, resetPin = "482913", "572039", "613408"
|
|
|
|
pinErr := func(err error) *PinError {
|
|
t.Helper()
|
|
var pe *PinError
|
|
require.True(t, errors.As(err, &pe), "want a PinError, got %v", err)
|
|
for _, secret := range []string{pin, newPin, resetPin} {
|
|
assert.NotContains(t, err.Error(), secret, "an error must never contain a PIN")
|
|
}
|
|
return pe
|
|
}
|
|
events := func() []string {
|
|
t.Helper()
|
|
var out []string
|
|
require.NoError(t, db.Raw(`SELECT event FROM customer_security_events WHERE customer_id = ? ORDER BY created_at, id`, customer).Scan(&out).Error)
|
|
return out
|
|
}
|
|
|
|
// No PIN yet: nothing can be approved.
|
|
status, err := p.Status(ctx, customer)
|
|
require.NoError(t, err)
|
|
assert.False(t, status.HasPin)
|
|
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)).Code)
|
|
|
|
// Creating the first PIN takes an OTP sent to the customer's own number, for this
|
|
// purpose.
|
|
sent, err := p.RequestOtp(ctx, customer, PinOtpPurposeSetup)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{PinOtpPurposeSetup}, otp.sent)
|
|
|
|
loginOtp := otp.issue(phone, "login")
|
|
assert.ErrorIs(t, p.CreatePin(ctx, customer, loginOtp.Token, loginOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP for another purpose")
|
|
strangerOtp := otp.issue("0899999999", PinOtpPurposeSetup)
|
|
assert.ErrorIs(t, p.CreatePin(ctx, customer, strangerOtp.Token, strangerOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP sent to another number")
|
|
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "000000", pin, pin, info), ErrPinOtpInvalid, "a wrong code")
|
|
|
|
// A weak PIN is refused before the OTP is used, so the same OTP still works after.
|
|
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "123456", "123456", info), ErrInvalidPinInput)
|
|
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "140390", "140390", info), ErrInvalidPinInput, "birth date")
|
|
require.NoError(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info))
|
|
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info), ErrPinAlreadySet)
|
|
|
|
var stored string
|
|
require.NoError(t, db.Raw(`SELECT pin_hash FROM customers WHERE id = ?`, customer).Scan(&stored).Error)
|
|
assert.NotContains(t, stored, pin, "only a hash is stored")
|
|
assert.True(t, strings.HasPrefix(stored, "$2"), "bcrypt")
|
|
|
|
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
|
|
|
// Four wrong attempts count down; the fifth locks for 30 minutes.
|
|
for left := 4; left >= 1; left-- {
|
|
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
|
assert.Equal(t, PinErrInvalid, pe.Code)
|
|
assert.Equal(t, left, pe.RemainingAttempts)
|
|
}
|
|
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
|
assert.Equal(t, PinErrLocked, pe.Code)
|
|
assert.WithinDuration(t, clock.Add(30*time.Minute), *pe.Until, time.Second)
|
|
assert.Len(t, alerts.messages, 1, "the customer is told the PIN locked")
|
|
|
|
// While locked even the right PIN is refused.
|
|
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
|
assert.Equal(t, PinErrLocked, pe.Code)
|
|
status, err = p.Status(ctx, customer)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, status.LockedUntil)
|
|
|
|
// Once the lock runs out a wrong PIN starts a new series of five.
|
|
advance(31 * time.Minute)
|
|
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
|
assert.Equal(t, PinErrInvalid, pe.Code)
|
|
assert.Equal(t, 4, pe.RemainingAttempts)
|
|
// The right PIN resets the count.
|
|
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
|
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
|
assert.Equal(t, 4, pe.RemainingAttempts)
|
|
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
|
|
|
// Wrong attempts made at once all count: none slips past the lock.
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); _ = p.VerifyPin(ctx, customer, "000001", PinActionPay, info) }()
|
|
}
|
|
wg.Wait()
|
|
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
|
assert.Equal(t, PinErrLocked, pe.Code)
|
|
|
|
// Resetting through OTP lifts the lock and holds transfers for 24 hours.
|
|
_, err = p.RequestOtp(ctx, customer, PinOtpPurposeReset)
|
|
require.NoError(t, err)
|
|
setupOtp := otp.issue(phone, PinOtpPurposeSetup)
|
|
assert.ErrorIs(t, p.ResetPin(ctx, customer, setupOtp.Token, setupOtp.Code, resetPin, resetPin, info), ErrPinOtpInvalid, "a setup OTP cannot reset")
|
|
resetOtp := otp.issue(phone, PinOtpPurposeReset)
|
|
require.NoError(t, p.ResetPin(ctx, customer, resetOtp.Token, resetOtp.Code, resetPin, resetPin, info))
|
|
status, err = p.Status(ctx, customer)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, status.LockedUntil, "the lock is lifted")
|
|
require.NotNil(t, status.TransferBlockedUntil)
|
|
assert.WithinDuration(t, clock.Add(24*time.Hour), *status.TransferBlockedUntil, time.Second)
|
|
|
|
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionPay, info), "paying still works")
|
|
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionExchange, info), "exchanging still works")
|
|
pe = pinErr(p.VerifyPin(ctx, customer, resetPin, PinActionTransfer, info))
|
|
assert.Equal(t, PinErrTransferBlocked, pe.Code)
|
|
var failed int
|
|
require.NoError(t, db.Raw(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, customer).Scan(&failed).Error)
|
|
assert.Zero(t, failed, "a held transfer costs no attempt")
|
|
|
|
// Changing the PIN needs the old one and keeps the transfer hold.
|
|
assert.Equal(t, PinErrInvalid, pinErr(p.ChangePin(ctx, customer, "000001", newPin, newPin, info)).Code)
|
|
require.NoError(t, p.ChangePin(ctx, customer, resetPin, newPin, newPin, info))
|
|
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionPay, info))
|
|
assert.Equal(t, PinErrTransferBlocked, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info)).Code)
|
|
advance(25 * time.Hour)
|
|
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info), "the hold ends after 24 hours")
|
|
|
|
// An admin can remove the PIN, only in their own organization and with a reason.
|
|
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, otherOrg, customer, admin, "hilang HP", info), repository.ErrPinCustomerNotFound)
|
|
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, org, customer, admin, " ", info), ErrInvalidPinInput)
|
|
require.NoError(t, p.RemovePinByAdmin(ctx, org, customer, admin, "hilang HP", info))
|
|
status, err = p.Status(ctx, customer)
|
|
require.NoError(t, err)
|
|
assert.False(t, status.HasPin)
|
|
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionPay, info)).Code)
|
|
|
|
// Every event is in the security log, with where it came from.
|
|
got := events()
|
|
for _, want := range []string{PinEventSet, PinEventFailed, PinEventLocked, PinEventReset, PinEventChanged, PinEventRemovedByAdmin} {
|
|
assert.Contains(t, got, want)
|
|
}
|
|
page, err := p.ListEvents(ctx, org, customer, 1, 100)
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, len(got), page.Pagination.Total)
|
|
removed := page.Data[0]
|
|
assert.Equal(t, PinEventRemovedByAdmin, removed.Event)
|
|
assert.Equal(t, &admin, removed.ActorUser)
|
|
assert.Equal(t, "hilang HP", *removed.Reason)
|
|
assert.Equal(t, "10.0.0.7", *removed.IPAddress)
|
|
_, err = p.ListEvents(ctx, otherOrg, customer, 1, 10)
|
|
assert.ErrorIs(t, err, repository.ErrPinCustomerNotFound)
|
|
|
|
var locked int
|
|
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM customer_security_events WHERE customer_id = ? AND event = ?`, customer, PinEventLocked).Scan(&locked).Error)
|
|
assert.Equal(t, locked, len(alerts.messages), "one alert per lock")
|
|
}
|