@@ -751,6 +751,74 @@ func TestUpgrade_SameDigestCrossRepositoryMovePersistsAuthorizedReference(t *tes
751751 assert .Equal (t , []byte ("preserve me" ), sentinelContents )
752752}
753753
754+ //nolint:paralleltest // serial: real sqlite + on-disk client materialization per test
755+ func TestUpgrade_SameDigestMetadataRollbackSurvivesCancellationAndConflict (t * testing.T ) {
756+ inner , projectRoot , _ , _ := newPluginKeyReanchorFixture (t )
757+ newRef := "ghcr.io/new-org/my-plugin:v2"
758+ require .NoError (t , lockfile .Update (mustOpenRoot (t , projectRoot ), func (lf * lockfile.Lockfile ) error {
759+ entry , ok := lf .GetPlugin ("my-plugin" )
760+ require .True (t , ok )
761+ entry .Source = newRef
762+ lf .UpsertPlugin (entry )
763+ return nil
764+ }))
765+
766+ beforeInstalled , err := inner .store .Get (t .Context (), "my-plugin" , plugins .ScopeProject , projectRoot )
767+ require .NoError (t , err )
768+ callerCtx , cancel := context .WithCancel (t .Context ())
769+ t .Cleanup (cancel )
770+ var updateContextErrors []error
771+ var hookErr error
772+ var competingEntry lockfile.Entry
773+ hookedStore := & hookPluginStore {
774+ PluginStore : inner .store ,
775+ afterUpdate : func (updateCtx context.Context , call int ) {
776+ updateContextErrors = append (updateContextErrors , updateCtx .Err ())
777+ if call != 1 {
778+ return
779+ }
780+ hookErr = lockfile .Update (mustOpenRoot (t , projectRoot ), func (lf * lockfile.Lockfile ) error {
781+ entry , exists := lf .GetPlugin ("my-plugin" )
782+ if ! exists {
783+ return fmt .Errorf ("planned lock entry disappeared" )
784+ }
785+ entry .Digest = validLockDigestAlt ()
786+ lf .UpsertPlugin (entry )
787+ competingEntry = entry
788+ return nil
789+ })
790+ cancel ()
791+ },
792+ }
793+ inner .store = hookedStore
794+
795+ result , err := inner .Upgrade (callerCtx , plugins.UpgradeOptions {
796+ ProjectRoot : projectRoot ,
797+ AllowRefChange : true ,
798+ AllowSignerChange : true ,
799+ PublicKey : reanchorPublicKeyB64 ,
800+ })
801+ require .NoError (t , err )
802+ require .NoError (t , hookErr )
803+ require .Len (t , result .Outcomes , 1 )
804+ assert .Equal (t , plugins .UpgradeStatusFailed , result .Outcomes [0 ].Status )
805+ assert .Contains (t , result .Outcomes [0 ].Error , lockfile .ErrEntryChanged .Error ())
806+ assert .ErrorIs (t , callerCtx .Err (), context .Canceled )
807+ require .Len (t , updateContextErrors , 2 ,
808+ "the metadata update must be followed by a compensating database update" )
809+ assert .NoError (t , updateContextErrors [0 ])
810+ assert .NoError (t , updateContextErrors [1 ],
811+ "compensation must detach from the canceled request context" )
812+
813+ afterInstalled , getErr := inner .store .Get (t .Context (), "my-plugin" , plugins .ScopeProject , projectRoot )
814+ require .NoError (t , getErr )
815+ assert .Equal (t , beforeInstalled , afterInstalled )
816+ afterEntry , ok := loadPluginLockEntry (t , projectRoot )
817+ require .True (t , ok )
818+ assert .Equal (t , competingEntry , afterEntry ,
819+ "rollback must not overwrite the competing lock update" )
820+ }
821+
754822func trustOnlyReanchorPlan (entry lockfile.Entry ) upgradePlan {
755823 return upgradePlan {
756824 entry : entry ,
0 commit comments