@@ -719,3 +719,240 @@ func TestManager_GetByTMSID(t *testing.T) {
719719 auditor .Get (sp , w )
720720 })
721721}
722+
723+ // ---------------------------------------------------------------------------
724+ // Service.Audit Lock Management Tests
725+ // ---------------------------------------------------------------------------
726+
727+ // TestService_Audit_LocksReleasedOnAuditRecordError verifies that when Audit() fails
728+ // before lock acquisition (during AuditRecord()), no locks are held and Release() is safe.
729+ // Audit() acquires locks ONLY after successful AuditRecord(), so early failures don't leak locks.
730+ func TestService_Audit_LocksReleasedOnAuditRecordError (t * testing.T ) {
731+ // Create a TMS that will fail on AuditRecord by returning nil public parameters
732+ mockTMS := & drivermock.TokenManagerService {}
733+ mockPPM := & drivermock.PublicParamsManager {}
734+ mockPPM .PublicParametersReturns (nil ) // This will cause AuditRecord to fail
735+ mockTMS .PublicParamsManagerReturns (mockPPM )
736+ mockTMS .ValidatorReturns (& drivermock.Validator {}, nil )
737+ mockTMS .TokensServiceReturns (& drivermock.TokensService {})
738+ mockTMS .WalletServiceReturns (& drivermock.WalletService {})
739+
740+ mockVP := & tokenmock.VaultProvider {}
741+ mockV := & drivermock.Vault {}
742+ mockV .QueryEngineReturns (& drivermock.QueryEngine {})
743+ mockVP .VaultReturns (mockV , nil )
744+
745+ badTMS , err := token .NewManagementService (
746+ token.TMSID {}, mockTMS , logging .MustGetLogger ("test" ), mockVP , nil , nil ,
747+ )
748+ require .NoError (t , err )
749+
750+ storeService := newTestStoreService (t , newFakeStore ())
751+ svc := newTestService (storeService , nil )
752+
753+ tx := & auditmock.Transaction {}
754+ tx .IDReturns ("tx-audit-record-err" )
755+ tx .RequestReturns (token .NewRequest (badTMS , token .RequestAnchor ("tx-audit-record-err" )))
756+
757+ // Audit should fail
758+ _ , _ , err = svc .Audit (context .Background (), tx )
759+ require .Error (t , err )
760+ assert .Contains (t , err .Error (), "failed getting transaction audit record" )
761+
762+ // Release should be safe to call even though Audit failed
763+ assert .NotPanics (t , func () {
764+ svc .Release (context .Background (), tx )
765+ })
766+
767+ // Verify no locks are held by trying to acquire the same anchor
768+ ctx := context .Background ()
769+ err = storeService .AcquireLocks (ctx , "tx-audit-record-err" )
770+ require .NoError (t , err , "should be able to acquire locks since Audit failed" )
771+ storeService .ReleaseLocks (ctx , "tx-audit-record-err" )
772+ }
773+
774+ // TestService_Audit_LocksAcquiredOnSuccess verifies successful Audit() acquires locks,
775+ // Release() frees them, and Release() is idempotent (safe to call multiple times).
776+ func TestService_Audit_LocksAcquiredOnSuccess (t * testing.T ) {
777+ storeService := newTestStoreService (t , newFakeStore ())
778+ svc := newTestService (storeService , nil )
779+
780+ tx := & auditmock.Transaction {}
781+ tx .IDReturns ("tx-audit-success" )
782+ tx .RequestReturns (token .NewRequest (newTestManagementService (t ), token .RequestAnchor ("tx-audit-success" )))
783+
784+ ctx := context .Background ()
785+
786+ // Audit should succeed
787+ inputs , outputs , err := svc .Audit (ctx , tx )
788+ require .NoError (t , err )
789+ assert .NotNil (t , inputs )
790+ assert .NotNil (t , outputs )
791+
792+ // Verify Release is safe to call
793+ assert .NotPanics (t , func () {
794+ svc .Release (ctx , tx )
795+ })
796+
797+ // Verify Release is idempotent
798+ assert .NotPanics (t , func () {
799+ svc .Release (ctx , tx )
800+ })
801+ }
802+
803+ // TestService_Audit_ContextCancellationBeforeLockAcquisition verifies context cancellation
804+ // doesn't leak locks. Semaphore auto-rolls back partially acquired locks (PR #1616).
805+ // Release() is always safe regardless of Audit() outcome.
806+ func TestService_Audit_ContextCancellationBeforeLockAcquisition (t * testing.T ) {
807+ storeService := newTestStoreService (t , newFakeStore ())
808+ svc := newTestService (storeService , nil )
809+
810+ tx := & auditmock.Transaction {}
811+ tx .IDReturns ("tx-ctx-cancel" )
812+ tx .RequestReturns (token .NewRequest (newTestManagementService (t ), token .RequestAnchor ("tx-ctx-cancel" )))
813+
814+ // Use a cancelled context
815+ cancelledCtx , cancel := context .WithCancel (context .Background ())
816+ cancel ()
817+
818+ // Audit may fail due to context cancellation (depending on timing)
819+ // or succeed if AuditRecord completes before cancellation check
820+ _ , _ , _ = svc .Audit (cancelledCtx , tx )
821+ // We don't assert error here as it depends on timing
822+
823+ // Release should always be safe to call
824+ assert .NotPanics (t , func () {
825+ svc .Release (context .Background (), tx )
826+ })
827+
828+ // Verify we can acquire locks after (no locks were leaked)
829+ ctx := context .Background ()
830+ err := storeService .AcquireLocks (ctx , "tx-ctx-cancel" )
831+ require .NoError (t , err , "should be able to acquire locks" )
832+ storeService .ReleaseLocks (ctx , "tx-ctx-cancel" )
833+ }
834+
835+ // TestService_Audit_MultipleAuditsSequential verifies sequential audits work correctly:
836+ // first Audit() acquires locks, Release() frees them, second Audit() succeeds.
837+ func TestService_Audit_MultipleAuditsSequential (t * testing.T ) {
838+ storeService := newTestStoreService (t , newFakeStore ())
839+ svc := newTestService (storeService , nil )
840+
841+ ctx := context .Background ()
842+
843+ // First audit
844+ tx1 := & auditmock.Transaction {}
845+ tx1 .IDReturns ("tx-audit-1" )
846+ tx1 .RequestReturns (token .NewRequest (newTestManagementService (t ), token .RequestAnchor ("tx-audit-1" )))
847+
848+ inputs1 , outputs1 , err := svc .Audit (ctx , tx1 )
849+ require .NoError (t , err )
850+ assert .NotNil (t , inputs1 )
851+ assert .NotNil (t , outputs1 )
852+
853+ // Release first audit's locks
854+ svc .Release (ctx , tx1 )
855+
856+ // Second audit should succeed
857+ tx2 := & auditmock.Transaction {}
858+ tx2 .IDReturns ("tx-audit-2" )
859+ tx2 .RequestReturns (token .NewRequest (newTestManagementService (t ), token .RequestAnchor ("tx-audit-2" )))
860+
861+ inputs2 , outputs2 , err := svc .Audit (ctx , tx2 )
862+ require .NoError (t , err )
863+ assert .NotNil (t , inputs2 )
864+ assert .NotNil (t , outputs2 )
865+
866+ // Clean up
867+ svc .Release (ctx , tx2 )
868+ }
869+
870+ // TestService_Audit_ReleaseIdempotency verifies Release() is idempotent - can be called
871+ // multiple times safely without panics (handles error paths, defer, retry logic).
872+ func TestService_Audit_ReleaseIdempotency (t * testing.T ) {
873+ storeService := newTestStoreService (t , newFakeStore ())
874+ svc := newTestService (storeService , nil )
875+
876+ tx := & auditmock.Transaction {}
877+ tx .IDReturns ("tx-release-idempotent" )
878+ tx .RequestReturns (token .NewRequest (newTestManagementService (t ), token .RequestAnchor ("tx-release-idempotent" )))
879+
880+ // Audit to acquire locks
881+ _ , _ , err := svc .Audit (context .Background (), tx )
882+ require .NoError (t , err )
883+
884+ ctx := context .Background ()
885+
886+ // First release should work
887+ assert .NotPanics (t , func () {
888+ svc .Release (ctx , tx )
889+ })
890+
891+ // Second release should also be safe (no-op)
892+ assert .NotPanics (t , func () {
893+ svc .Release (ctx , tx )
894+ })
895+
896+ // Third release should still be safe
897+ assert .NotPanics (t , func () {
898+ svc .Release (ctx , tx )
899+ })
900+ }
901+
902+ // TestService_Audit_ReleaseWithoutAudit verifies Release() is safe to call without
903+ // prior Audit() (handles defer in error paths where Audit() never ran or failed early).
904+ func TestService_Audit_ReleaseWithoutAudit (t * testing.T ) {
905+ storeService := newTestStoreService (t , newFakeStore ())
906+ svc := newTestService (storeService , nil )
907+
908+ tx := & auditmock.Transaction {}
909+ tx .IDReturns ("tx-no-audit" )
910+ tx .RequestReturns (token .NewRequest (newTestManagementService (t ), token .RequestAnchor ("tx-no-audit" )))
911+
912+ // Release without Audit should be safe
913+ assert .NotPanics (t , func () {
914+ svc .Release (context .Background (), tx )
915+ })
916+ }
917+
918+ // TestService_Audit_PanicRecoveryReleasesLocks verifies defer Release() executes even
919+ // when code panics, preventing lock leaks. Demonstrates correct pattern:
920+ //
921+ // defer auditor.Release(ctx, tx) // MUST be after error check
922+ func TestService_Audit_PanicRecoveryReleasesLocks (t * testing.T ) {
923+ storeService := newTestStoreService (t , newFakeStore ())
924+ svc := newTestService (storeService , nil )
925+
926+ tx := & auditmock.Transaction {}
927+ tx .IDReturns ("tx-panic-recovery" )
928+ tx .RequestReturns (token .NewRequest (newTestManagementService (t ), token .RequestAnchor ("tx-panic-recovery" )))
929+
930+ ctx := context .Background ()
931+
932+ // Simulate code that panics after Audit but has defer Release
933+ func () {
934+ defer func () {
935+ if r := recover (); r != nil {
936+ // Panic recovered as expected
937+ assert .Equal (t , "simulated panic" , r )
938+ }
939+ }()
940+
941+ // Audit succeeds and acquires locks
942+ inputs , outputs , err := svc .Audit (ctx , tx )
943+ require .NoError (t , err )
944+ assert .NotNil (t , inputs )
945+ assert .NotNil (t , outputs )
946+
947+ // Defer Release - this should execute even if panic occurs
948+ defer svc .Release (ctx , tx )
949+
950+ // Simulate panic in subsequent processing
951+ panic ("simulated panic" )
952+ }()
953+
954+ // Verify locks were released by attempting to acquire them
955+ err := storeService .AcquireLocks (ctx , "tx-panic-recovery" )
956+ require .NoError (t , err , "locks should have been released despite panic" )
957+ storeService .ReleaseLocks (ctx , "tx-panic-recovery" )
958+ }
0 commit comments