2026-09-30 11:20:29 +07:00
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
}
2026-09-30 12:33:20 +07:00
func ( f * alerterFake ) Notify ( _ context . Context , _ uuid . UUID , _ , body string , _ map [ string ] string ) error {
2026-09-30 11:20:29 +07:00
f . mu . Lock ()
defer f . mu . Unlock ()
2026-09-30 12:33:20 +07:00
f . messages = append ( f . messages , body )
2026-09-30 11:20:29 +07:00
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" )
}