Files
apskel-pos-backend/internal/processor/customer_pin_processor_db_test.go
2026-09-30 15:31:44 +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) 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")
}