@@ -3313,9 +3313,15 @@ func TestHandler_revokePurchase_ValidToken(t *testing.T) {
33133313 mockStore .GetCloudAccountFn = func (_ context.Context , id string ) (* config.CloudAccount , error ) {
33143314 return & config.CloudAccount {ID : id , ContactEmail : revokerEmail }, nil
33153315 }
3316- // SavePurchaseExecution for the revocation_requested update.
3316+ // Atomic conditional transition completed/partially_completed ->
3317+ // revocation_requested, returning the updated row.
3318+ mockStore .On ("TransitionExecutionStatus" , ctx , execID ,
3319+ []string {"completed" , "partially_completed" }, "revocation_requested" ).
3320+ Return (& config.PurchaseExecution {ExecutionID : execID , Status : "revocation_requested" }, nil )
3321+ // SavePurchaseExecution stamps CancelledBy on the returned row.
33173322 mockStore .On ("SavePurchaseExecution" , ctx , mock .MatchedBy (func (e * config.PurchaseExecution ) bool {
3318- return e .ExecutionID == execID && e .Status == "revocation_requested"
3323+ return e .ExecutionID == execID && e .Status == "revocation_requested" &&
3324+ e .CancelledBy != nil && * e .CancelledBy == revokerEmail
33193325 })).Return (nil )
33203326
33213327 mockAuth := new (MockAuthService )
@@ -3425,6 +3431,190 @@ func TestHandler_revokePurchase_NotFound(t *testing.T) {
34253431 assert .Equal (t , 404 , ce .code )
34263432}
34273433
3434+ // TestHandler_revokePurchase_ExpiredToken verifies that a matching token whose
3435+ // ApprovalTokenExpiresAt deadline has passed is rejected with 409, so a stale
3436+ // completed execution cannot be marked revocation_requested via an old link.
3437+ // Regression test for the revoke-token expiry CR finding (PR #889).
3438+ func TestHandler_revokePurchase_ExpiredToken (t * testing.T ) {
3439+ ctx := context .Background ()
3440+ execID := "55555555-5555-5555-5555-555555555555"
3441+ token := "expiredbutmatching"
3442+ contactEmail := "contact@acct.example.com"
3443+ accountID := "acct-1"
3444+ past := time .Now ().Add (- 1 * time .Hour )
3445+
3446+ exec := & config.PurchaseExecution {
3447+ ExecutionID : execID ,
3448+ ApprovalToken : token ,
3449+ ApprovalTokenExpiresAt : & past ,
3450+ Status : "completed" ,
3451+ Recommendations : []config.RecommendationRecord {
3452+ {ID : "r1" , CloudAccountID : & accountID },
3453+ },
3454+ }
3455+
3456+ mockStore := new (MockConfigStore )
3457+ mockStore .On ("GetExecutionByID" , ctx , execID ).Return (exec , nil )
3458+ mockStore .On ("GetGlobalConfig" , ctx ).Return (& config.GlobalConfig {}, nil )
3459+ mockStore .GetCloudAccountFn = func (_ context.Context , id string ) (* config.CloudAccount , error ) {
3460+ return & config.CloudAccount {ID : id , ContactEmail : contactEmail }, nil
3461+ }
3462+
3463+ mockAuth := new (MockAuthService )
3464+ mockAuth .On ("ValidateSession" , ctx , "sess-tok" ).Return (& Session {Email : contactEmail }, nil )
3465+ mockAuth .On ("HasPermissionAPI" , ctx , "" , "cancel-any" , "purchases" ).Return (false , nil ).Maybe ()
3466+ mockAuth .On ("HasPermissionAPI" , ctx , "" , "cancel-own" , "purchases" ).Return (false , nil ).Maybe ()
3467+
3468+ handler := & Handler {config : mockStore , auth : mockAuth }
3469+ req := & events.LambdaFunctionURLRequest {
3470+ Headers : map [string ]string {"authorization" : "Bearer sess-tok" },
3471+ QueryStringParameters : map [string ]string {"token" : token },
3472+ }
3473+ _ , err := handler .revokePurchase (ctx , req , execID , token )
3474+ require .Error (t , err , "expired revocation token must be rejected" )
3475+ ce , ok := IsClientError (err )
3476+ require .True (t , ok , "expected a client error" )
3477+ assert .Equal (t , 409 , ce .code )
3478+ assert .Contains (t , ce .message , "expired" )
3479+ }
3480+
3481+ // TestHandler_revokePurchase_SessionAdminCancelAny exercises the tokenless
3482+ // session-authorized revoke branch (tryRevokeViaSession -> revokeViaSession)
3483+ // for an admin holding cancel-any on purchases. It asserts the atomic
3484+ // TransitionExecutionStatus call and the revocation_requested result.
3485+ // Covers the session-auth revoke branch CR finding (PR #889).
3486+ func TestHandler_revokePurchase_SessionAdminCancelAny (t * testing.T ) {
3487+ ctx := context .Background ()
3488+ execID := "66666666-6666-6666-6666-666666666666"
3489+ adminEmail := "admin@example.com"
3490+ adminUserID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
3491+
3492+ exec := buildCompletedExec (execID , "unused-token" )
3493+
3494+ mockStore := new (MockConfigStore )
3495+ mockStore .On ("GetExecutionByID" , ctx , execID ).Return (exec , nil )
3496+ mockStore .On ("TransitionExecutionStatus" , ctx , execID ,
3497+ []string {"completed" , "partially_completed" }, "revocation_requested" ).
3498+ Return (& config.PurchaseExecution {ExecutionID : execID , Status : "revocation_requested" }, nil )
3499+ mockStore .On ("SavePurchaseExecution" , ctx , mock .MatchedBy (func (e * config.PurchaseExecution ) bool {
3500+ return e .ExecutionID == execID && e .CancelledBy != nil && * e .CancelledBy == adminEmail
3501+ })).Return (nil )
3502+
3503+ mockAuth := new (MockAuthService )
3504+ mockAuth .On ("ValidateSession" , ctx , "admin-token" ).
3505+ Return (& Session {UserID : adminUserID , Email : adminEmail }, nil )
3506+ mockAuth .On ("HasPermissionAPI" , ctx , adminUserID , "cancel-any" , "purchases" ).Return (true , nil )
3507+
3508+ handler := & Handler {config : mockStore , auth : mockAuth }
3509+ // No token query param: forces the tokenless session-auth path.
3510+ req := & events.LambdaFunctionURLRequest {
3511+ Headers : map [string ]string {"authorization" : "Bearer admin-token" },
3512+ }
3513+ result , err := handler .revokePurchase (ctx , req , execID , "" )
3514+ require .NoError (t , err , "session admin with cancel-any must succeed" )
3515+ resultMap := result .(map [string ]string )
3516+ assert .Equal (t , "revocation_requested" , resultMap ["status" ])
3517+ mockStore .AssertExpectations (t )
3518+ mockAuth .AssertExpectations (t )
3519+ }
3520+
3521+ // TestHandler_revokePurchase_SessionOwnerCancelOwn exercises the tokenless
3522+ // session-auth branch for a non-admin owner holding cancel-own on a purchase
3523+ // they created. Covers the session-auth revoke branch CR finding (PR #889).
3524+ func TestHandler_revokePurchase_SessionOwnerCancelOwn (t * testing.T ) {
3525+ ctx := context .Background ()
3526+ execID := "77777777-7777-7777-7777-777777777777"
3527+ ownerEmail := "owner@example.com"
3528+ ownerUserID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
3529+
3530+ exec := buildCompletedExec (execID , "unused-token" )
3531+ exec .CreatedByUserID = & ownerUserID
3532+
3533+ mockStore := new (MockConfigStore )
3534+ mockStore .On ("GetExecutionByID" , ctx , execID ).Return (exec , nil )
3535+ mockStore .On ("TransitionExecutionStatus" , ctx , execID ,
3536+ []string {"completed" , "partially_completed" }, "revocation_requested" ).
3537+ Return (& config.PurchaseExecution {ExecutionID : execID , Status : "revocation_requested" }, nil )
3538+ mockStore .On ("SavePurchaseExecution" , ctx , mock .MatchedBy (func (e * config.PurchaseExecution ) bool {
3539+ return e .ExecutionID == execID && e .CancelledBy != nil && * e .CancelledBy == ownerEmail
3540+ })).Return (nil )
3541+
3542+ mockAuth := new (MockAuthService )
3543+ mockAuth .On ("ValidateSession" , ctx , "owner-token" ).
3544+ Return (& Session {UserID : ownerUserID , Email : ownerEmail }, nil )
3545+ mockAuth .On ("HasPermissionAPI" , ctx , ownerUserID , "cancel-any" , "purchases" ).Return (false , nil )
3546+ mockAuth .On ("HasPermissionAPI" , ctx , ownerUserID , "cancel-own" , "purchases" ).Return (true , nil )
3547+
3548+ handler := & Handler {config : mockStore , auth : mockAuth }
3549+ req := & events.LambdaFunctionURLRequest {
3550+ Headers : map [string ]string {"authorization" : "Bearer owner-token" },
3551+ }
3552+ result , err := handler .revokePurchase (ctx , req , execID , "" )
3553+ require .NoError (t , err , "session owner with cancel-own must succeed" )
3554+ resultMap := result .(map [string ]string )
3555+ assert .Equal (t , "revocation_requested" , resultMap ["status" ])
3556+ mockStore .AssertExpectations (t )
3557+ mockAuth .AssertExpectations (t )
3558+ }
3559+
3560+ // TestHandler_revokePurchase_SessionNoPermissionNoToken verifies that a session
3561+ // lacking both cancel-any and cancel-own, with no token supplied, is denied:
3562+ // the session branch returns permission-denied and falls through to the
3563+ // tokenless 401. Covers the session-auth revoke branch CR finding (PR #889).
3564+ func TestHandler_revokePurchase_SessionNoPermissionNoToken (t * testing.T ) {
3565+ ctx := context .Background ()
3566+ execID := "88888888-8888-8888-8888-888888888888"
3567+ userEmail := "nobody@example.com"
3568+ userID := "cccccccc-cccc-cccc-cccc-cccccccccccc"
3569+
3570+ exec := buildCompletedExec (execID , "unused-token" )
3571+
3572+ mockStore := new (MockConfigStore )
3573+ mockStore .On ("GetExecutionByID" , ctx , execID ).Return (exec , nil )
3574+
3575+ mockAuth := new (MockAuthService )
3576+ mockAuth .On ("ValidateSession" , ctx , "user-token" ).
3577+ Return (& Session {UserID : userID , Email : userEmail }, nil )
3578+ mockAuth .On ("HasPermissionAPI" , ctx , userID , "cancel-any" , "purchases" ).Return (false , nil )
3579+ mockAuth .On ("HasPermissionAPI" , ctx , userID , "cancel-own" , "purchases" ).Return (false , nil )
3580+
3581+ handler := & Handler {config : mockStore , auth : mockAuth }
3582+ req := & events.LambdaFunctionURLRequest {
3583+ Headers : map [string ]string {"authorization" : "Bearer user-token" },
3584+ }
3585+ _ , err := handler .revokePurchase (ctx , req , execID , "" )
3586+ require .Error (t , err , "no permission and no token must be denied" )
3587+ ce , ok := IsClientError (err )
3588+ require .True (t , ok , "expected a client error" )
3589+ assert .Equal (t , 401 , ce .code )
3590+ mockStore .AssertExpectations (t )
3591+ mockAuth .AssertExpectations (t )
3592+ }
3593+
3594+ // TestHandler_revokeViaSession_ConcurrentTransition verifies that a lost-update
3595+ // race (the row's status changed between read and the conditional UPDATE) is
3596+ // surfaced as a 409 rather than silently overwriting. Regression test for the
3597+ // atomic-status-transition CR finding (PR #889).
3598+ func TestHandler_revokeViaSession_ConcurrentTransition (t * testing.T ) {
3599+ ctx := context .Background ()
3600+ execID := "99999999-9999-9999-9999-999999999999"
3601+
3602+ exec := buildCompletedExec (execID , "tok" )
3603+
3604+ mockStore := new (MockConfigStore )
3605+ mockStore .On ("TransitionExecutionStatus" , ctx , execID ,
3606+ []string {"completed" , "partially_completed" }, "revocation_requested" ).
3607+ Return (nil , fmt .Errorf ("%w: execution %s" , config .ErrExecutionNotInExpectedStatus , execID ))
3608+
3609+ handler := & Handler {config : mockStore }
3610+ _ , err := handler .revokeViaSession (ctx , exec , "someone@example.com" )
3611+ require .Error (t , err , "a concurrent transition must surface an error" )
3612+ ce , ok := IsClientError (err )
3613+ require .True (t , ok , "expected a client error" )
3614+ assert .Equal (t , 409 , ce .code )
3615+ mockStore .AssertExpectations (t )
3616+ }
3617+
34283618// ---------------------------------------------------------------------------
34293619// resolveExecutedNotificationRecipients unit tests (issue #291)
34303620// ---------------------------------------------------------------------------
0 commit comments