Files
apskel-pos-backend/internal/processor/customer_pin_processor_db_test.go
T
efrilmandClaude Opus 5.5 8370851ed2 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>
2026-09-30 11:20:29 +07:00

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