@@ -330,11 +330,13 @@ func TestDeployNew_Redeploy_UpdateStatusError_StillAccepts(t *testing.T) {
330330 "" , "" , "" , // git_url, git_ref, git_token_enc (mig 065)
331331 ))
332332
333- // UPDATE deployments SET status = $1 ... → driver error. The handler
334- // must slog.Warn and CONTINUE (NOT return 5xx) — the redeploy itself
335- // is still useful because runRedeployAsync will flip the row later.
336- mock .ExpectExec (`UPDATE deployments\s+SET status = \$1, error_message = \$2, updated_at = now\(\)` ).
337- WithArgs ("building" , nil , rowID ).
333+ // MarkDeploymentBuilding (guarded CAS) → driver error. The handler must
334+ // slog.Warn and CONTINUE (NOT return 5xx) — a driver error is
335+ // non-determinate (we can't tell whether the flip landed), and
336+ // runRedeployAsync will reconcile the row later. Only an explicit 0-row
337+ // CAS miss means "reaped concurrently, return 409".
338+ mock .ExpectExec (`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN` ).
339+ WithArgs (rowID ).
338340 WillReturnError (errMockRedeployDriver )
339341
340342 // MatchExpectationsInOrder=false because the audit goroutine
@@ -448,6 +450,282 @@ func TestDeployNew_Redeploy_EmptyProviderID_Returns409(t *testing.T) {
448450 require .NoError (t , mock .ExpectationsWereMet ())
449451}
450452
453+ // TestDeployNew_Redeploy_CASMiss_Returns409 pins the #14 TOCTOU hardening on
454+ // the in-place path. FindActiveDeploymentByTeamEnvName returns a redeployable
455+ // row (status=healthy, provider_id set), but between that lookup and the
456+ // 'building' flip the reaper advanced the row to a terminal status. The
457+ // guarded CAS therefore matches 0 rows. The handler MUST 409
458+ // deployment_not_redeployable (NOT re-arm the dead workload as 'building').
459+ func TestDeployNew_Redeploy_CASMiss_Returns409 (t * testing.T ) {
460+ db , mock , err := sqlmock .New ()
461+ require .NoError (t , err )
462+ defer db .Close ()
463+
464+ app , teamID , _ := redeployMockApp (t , db )
465+ rowID := uuid .New ()
466+
467+ expectTeamLookupOK (mock , teamID , "pro" )
468+
469+ envVarsJSON , _ := json .Marshal (map [string ]string {"_name" : "raced-reaper" })
470+ mock .ExpectQuery (`FROM deployments\s+WHERE team_id = \$1\s+AND env = \$2\s+AND env_vars->>'_name' = \$3` ).
471+ WithArgs (teamID , "development" , "raced-reaper" ).
472+ WillReturnRows (sqlmock .NewRows (deploymentColumnsList ).AddRow (
473+ rowID ,
474+ teamID ,
475+ uuid.NullUUID {},
476+ "racedrpr" ,
477+ "app-racedrpr" , // non-empty provider_id → passes the not_ready guard
478+ "healthy" ,
479+ "https://racedrpr.deploy." ,
480+ envVarsJSON ,
481+ 8080 , "pro" , "development" ,
482+ false , "" ,
483+ sql.NullString {},
484+ time .Now (), time .Now (),
485+ sql.NullString {}, sql.NullString {}, "unset" , 0 ,
486+ sql.NullTime {}, "permanent" , 0 , sql.NullTime {},
487+ "tarball" , "" , "" , // source, image_ref, registry_creds_enc (mig 064)
488+ "" , "" , "" , // git_url, git_ref, git_token_enc (mig 065)
489+ ))
490+
491+ // Guarded CAS matches 0 rows — the reaper won the race. Handler 409s.
492+ mock .ExpectExec (`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN` ).
493+ WithArgs (rowID ).
494+ WillReturnResult (sqlmock .NewResult (0 , 0 ))
495+
496+ body , ct := multipartRedeployMockBody (t , map [string ]string {
497+ "name" : "raced-reaper" ,
498+ "redeploy" : "true" ,
499+ "port" : "8080" ,
500+ "env" : "development" ,
501+ })
502+ req := httptest .NewRequest (http .MethodPost , "/deploy/new" , body )
503+ req .Header .Set ("Content-Type" , ct )
504+
505+ resp , err := app .Test (req , 5000 )
506+ require .NoError (t , err )
507+ defer resp .Body .Close ()
508+ respBody , _ := io .ReadAll (resp .Body )
509+ require .Equal (t , http .StatusConflict , resp .StatusCode ,
510+ "a CAS miss (row reaped concurrently) must 409, not resurrect; body: %s" , string (respBody ))
511+
512+ var errBody struct {
513+ OK bool `json:"ok"`
514+ Error string `json:"error"`
515+ }
516+ require .NoError (t , json .Unmarshal (respBody , & errBody ))
517+ assert .False (t , errBody .OK )
518+ assert .Equal (t , errCodeDeploymentNotRedeployable , errBody .Error ,
519+ "CAS-miss response code must be deployment_not_redeployable" )
520+
521+ require .NoError (t , mock .ExpectationsWereMet ())
522+ }
523+
524+ // redeployByIDMockApp wires a Fiber app exposing POST /deploy/:id/redeploy
525+ // against a sqlmock-backed DB, with the same Locals-fake auth as
526+ // redeployMockApp. Used to exercise Redeploy's guarded-CAS branch
527+ // deterministically (the real-DB test in deploy_async_deployasync_test.go
528+ // can't interpose a reaper between GetDeploymentByAppID and the CAS flip).
529+ func redeployByIDMockApp (t * testing.T , db * sql.DB ) (* fiber.App , uuid.UUID ) {
530+ t .Helper ()
531+ teamID := uuid .New ()
532+
533+ cfg := & config.Config {
534+ JWTSecret : "test-secret-that-is-at-least-32-bytes-long!!" ,
535+ AESKey : "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" ,
536+ EnabledServices : "deploy" ,
537+ Environment : "test" ,
538+ }
539+ h := & DeployHandler {db : db , cfg : cfg , compute : noop .New (), planRegistry : plans .Default ()}
540+
541+ app := fiber .New (fiber.Config {
542+ BodyLimit : 50 * 1024 * 1024 ,
543+ ErrorHandler : func (c * fiber.Ctx , err error ) error {
544+ if errors .Is (err , ErrResponseWritten ) {
545+ return nil
546+ }
547+ code := fiber .StatusInternalServerError
548+ if e , ok := err .(* fiber.Error ); ok {
549+ code = e .Code
550+ }
551+ return c .Status (code ).JSON (fiber.Map {"ok" : false , "error" : "internal_error" , "message" : err .Error ()})
552+ },
553+ })
554+ app .Use (func (c * fiber.Ctx ) error {
555+ c .Locals (middleware .LocalKeyTeamID , teamID .String ())
556+ c .Locals (middleware .LocalKeyUserID , uuid .NewString ())
557+ return c .Next ()
558+ })
559+ app .Post ("/deploy/:id/redeploy" , h .Redeploy )
560+ return app , teamID
561+ }
562+
563+ // TestDeployRedeploy_ByID_CASMiss_Returns409 pins the #14 TOCTOU hardening on
564+ // the POST /deploy/:id/redeploy path. GetDeploymentByAppID returns a
565+ // non-terminal row (status=healthy, provider_id set) so the IsDeploymentTerminal
566+ // gate passes, but the reaper advances the row to a terminal status before the
567+ // 'building' flip. The guarded CAS matches 0 rows, and the handler must 409
568+ // deployment_not_redeployable rather than resurrecting the reaped workload.
569+ func TestDeployRedeploy_ByID_CASMiss_Returns409 (t * testing.T ) {
570+ db , mock , err := sqlmock .New ()
571+ require .NoError (t , err )
572+ defer db .Close ()
573+
574+ app , teamID := redeployByIDMockApp (t , db )
575+ rowID := uuid .New ()
576+ appID := "racedbyid"
577+
578+ expectTeamLookupOK (mock , teamID , "pro" )
579+
580+ envVarsJSON , _ := json .Marshal (map [string ]string {"_name" : "raced-by-id" })
581+ mock .ExpectQuery (`SELECT .* FROM deployments WHERE app_id = \$1` ).
582+ WithArgs (appID ).
583+ WillReturnRows (sqlmock .NewRows (deploymentColumnsList ).AddRow (
584+ rowID ,
585+ teamID ,
586+ uuid.NullUUID {},
587+ appID ,
588+ "app-racedbyid" , // non-empty provider_id → passes the not_ready guard
589+ "healthy" , // non-terminal → passes IsDeploymentTerminal gate
590+ "https://racedbyid.deploy." ,
591+ envVarsJSON ,
592+ 8080 , "pro" , "development" ,
593+ false , "" ,
594+ sql.NullString {},
595+ time .Now (), time .Now (),
596+ sql.NullString {}, sql.NullString {}, "unset" , 0 ,
597+ sql.NullTime {}, "permanent" , 0 , sql.NullTime {},
598+ "tarball" , "" , "" ,
599+ "" , "" , "" ,
600+ ))
601+
602+ // Guarded CAS matches 0 rows — the reaper won the race after the read.
603+ mock .ExpectExec (`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN` ).
604+ WithArgs (rowID ).
605+ WillReturnResult (sqlmock .NewResult (0 , 0 ))
606+
607+ body , ct := multipartRedeployMockBody (t , map [string ]string {"port" : "8080" })
608+ req := httptest .NewRequest (http .MethodPost , "/deploy/" + appID + "/redeploy" , body )
609+ req .Header .Set ("Content-Type" , ct )
610+
611+ resp , err := app .Test (req , 5000 )
612+ require .NoError (t , err )
613+ defer resp .Body .Close ()
614+ respBody , _ := io .ReadAll (resp .Body )
615+ require .Equal (t , http .StatusConflict , resp .StatusCode ,
616+ "a CAS miss on the :id redeploy path must 409, not resurrect; body: %s" , string (respBody ))
617+
618+ var errBody struct {
619+ OK bool `json:"ok"`
620+ Error string `json:"error"`
621+ }
622+ require .NoError (t , json .Unmarshal (respBody , & errBody ))
623+ assert .False (t , errBody .OK )
624+ assert .Equal (t , errCodeDeploymentNotRedeployable , errBody .Error )
625+
626+ require .NoError (t , mock .ExpectationsWereMet ())
627+ }
628+
629+ // TestDeployRedeploy_ByID_CASSuccess_Returns202 is the happy-path partner: the
630+ // guarded CAS matches 1 row (the deploy was still redeployable), so the
631+ // handler proceeds to 202 and launches the async build. Pins that the CAS
632+ // guard does NOT break the normal redeploy.
633+ func TestDeployRedeploy_ByID_CASSuccess_Returns202 (t * testing.T ) {
634+ db , mock , err := sqlmock .New ()
635+ require .NoError (t , err )
636+ defer db .Close ()
637+
638+ app , teamID := redeployByIDMockApp (t , db )
639+ rowID := uuid .New ()
640+ appID := "happybyid"
641+
642+ expectTeamLookupOK (mock , teamID , "pro" )
643+
644+ envVarsJSON , _ := json .Marshal (map [string ]string {"_name" : "happy-by-id" })
645+ mock .ExpectQuery (`SELECT .* FROM deployments WHERE app_id = \$1` ).
646+ WithArgs (appID ).
647+ WillReturnRows (sqlmock .NewRows (deploymentColumnsList ).AddRow (
648+ rowID , teamID , uuid.NullUUID {}, appID , "app-happybyid" , "healthy" ,
649+ "https://happybyid.deploy." , envVarsJSON ,
650+ 8080 , "pro" , "development" , false , "" , sql.NullString {},
651+ time .Now (), time .Now (),
652+ sql.NullString {}, sql.NullString {}, "unset" , 0 ,
653+ sql.NullTime {}, "permanent" , 0 , sql.NullTime {},
654+ "tarball" , "" , "" , "" , "" , "" ,
655+ ))
656+
657+ mock .ExpectExec (`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN` ).
658+ WithArgs (rowID ).
659+ WillReturnResult (sqlmock .NewResult (0 , 1 ))
660+
661+ // emitDeployAudit + runRedeployAsync race the response; don't pin further.
662+ mock .MatchExpectationsInOrder (false )
663+
664+ body , ct := multipartRedeployMockBody (t , map [string ]string {"port" : "8080" })
665+ req := httptest .NewRequest (http .MethodPost , "/deploy/" + appID + "/redeploy" , body )
666+ req .Header .Set ("Content-Type" , ct )
667+
668+ resp , err := app .Test (req , 5000 )
669+ require .NoError (t , err )
670+ defer resp .Body .Close ()
671+ respBody , _ := io .ReadAll (resp .Body )
672+ require .Equal (t , http .StatusAccepted , resp .StatusCode ,
673+ "a successful CAS (1 row) must 202 and launch the async build; body: %s" , string (respBody ))
674+
675+ time .Sleep (50 * time .Millisecond ) // let the async goroutines drain
676+ }
677+
678+ // TestDeployRedeploy_ByID_CASDriverError_StillAccepts pins the :id redeploy
679+ // driver-error arm of the guarded CAS. A driver error on MarkDeploymentBuilding
680+ // is non-determinate (we can't tell whether the flip landed), so the handler
681+ // must slog.Warn and CONTINUE to 202 — runRedeployAsync reconciles the status
682+ // later. Only an explicit 0-row CAS miss means "reaped, 409".
683+ func TestDeployRedeploy_ByID_CASDriverError_StillAccepts (t * testing.T ) {
684+ db , mock , err := sqlmock .New ()
685+ require .NoError (t , err )
686+ defer db .Close ()
687+
688+ app , teamID := redeployByIDMockApp (t , db )
689+ rowID := uuid .New ()
690+ appID := "drvbyid"
691+
692+ expectTeamLookupOK (mock , teamID , "pro" )
693+
694+ envVarsJSON , _ := json .Marshal (map [string ]string {"_name" : "drv-by-id" })
695+ mock .ExpectQuery (`SELECT .* FROM deployments WHERE app_id = \$1` ).
696+ WithArgs (appID ).
697+ WillReturnRows (sqlmock .NewRows (deploymentColumnsList ).AddRow (
698+ rowID , teamID , uuid.NullUUID {}, appID , "app-drvbyid" , "healthy" ,
699+ "https://drvbyid.deploy." , envVarsJSON ,
700+ 8080 , "pro" , "development" , false , "" , sql.NullString {},
701+ time .Now (), time .Now (),
702+ sql.NullString {}, sql.NullString {}, "unset" , 0 ,
703+ sql.NullTime {}, "permanent" , 0 , sql.NullTime {},
704+ "tarball" , "" , "" , "" , "" , "" ,
705+ ))
706+
707+ // Guarded CAS → driver error (non-determinate). Handler logs + continues.
708+ mock .ExpectExec (`UPDATE deployments\s+SET status = 'building', error_message = NULL, updated_at = now\(\)\s+WHERE id = \$1 AND status IN` ).
709+ WithArgs (rowID ).
710+ WillReturnError (errMockRedeployDriver )
711+
712+ // emitDeployAudit + runRedeployAsync race the response; don't pin further.
713+ mock .MatchExpectationsInOrder (false )
714+
715+ body , ct := multipartRedeployMockBody (t , map [string ]string {"port" : "8080" })
716+ req := httptest .NewRequest (http .MethodPost , "/deploy/" + appID + "/redeploy" , body )
717+ req .Header .Set ("Content-Type" , ct )
718+
719+ resp , err := app .Test (req , 5000 )
720+ require .NoError (t , err )
721+ defer resp .Body .Close ()
722+ respBody , _ := io .ReadAll (resp .Body )
723+ require .Equal (t , http .StatusAccepted , resp .StatusCode ,
724+ "a CAS driver error must NOT block the 202 accept; body: %s" , string (respBody ))
725+
726+ time .Sleep (50 * time .Millisecond ) // let the async goroutines drain
727+ }
728+
451729// TestDeployNew_Redeploy_MissingName_AfterValidation pins deploy.go:655-661.
452730// The branch fires when shouldRedeployInPlace is true AND name == "". In
453731// practice requireName at line 604 fires first on empty/whitespace input,
0 commit comments