feat(loyalty): customer PIN
Adds the 6-digit customer PIN that approves every action moving EnakPoint or EnakCoin on the customer's request (docs/prd-point-coin.md K8, F11, Q16, Q17, PC-301). Migration 000093 adds the PIN columns to customers and the customer_security_events table. PIN data is read and written only through CustomerPinRepository, never the Customer entity, so the hash cannot reach a customer response. Only a bcrypt hash is stored. - /customer/pin: status, OTP (pin_setup, pin_reset), create, change, reset. The OTP must be for that purpose and sent to the customer's own number; the existing OTP validation checks neither. A new PIN is checked (6 digits, confirmed, not one digit, not a run up or down, not the birth date as DDMMYY or YYMMDD) before the OTP is spent. - Five wrong attempts in a row lock the PIN for 30 minutes; the counter is incremented in one statement so attempts at the same time all count, and a lock that ran out starts a new series. A locked PIN is refused even when right. The customer is told by WhatsApp, as there is no push channel to customers yet; only the attempt that reached the limit alerts. - A reset through OTP lifts the lock and holds outgoing transfers for 24 hours; paying and exchanging still work, and a held transfer costs no attempt. - VerifyPin(ctx, customer, pin, action) for the flows that follow, with PIN_NOT_SET, PIN_INVALID (attempts left), PIN_LOCKED and TRANSFER_BLOCKED (until when), which PinErrorResponse turns into distinct codes and statuses. - DELETE /marketing/customers/:id/pin (loyalty managers, reason required) and GET /marketing/customers/:id/security-events, scoped to the organization. Every PIN event is in the security log with IP and user agent. No message or binding error contains a PIN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fc97c78300
commit
8370851ed2
@@ -0,0 +1,257 @@
|
||||
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) SendWhatsAppMessage(_ string, message string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.messages = append(f.messages, message)
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user