@@ -716,3 +716,237 @@ func TestParity_DependabotProxyFirstRunKeepsRenamedName(t *testing.T) {
716716 assert .Contains (t , lock , "'krzema12/github-actions-typing@v2.2.2':" )
717717 assert .NotContains (t , lock , "typesafegithub" )
718718}
719+
720+ // TestParity_VerifyMutableRefHistory: --verify checks that a commit locked
721+ // under a mutable ref is still in that ref's history, since a fork-network
722+ // commit exists under the upstream name. A legitimately moved ref stays
723+ // sticky and silent. Plain runs make no extra calls.
724+ func TestParity_VerifyMutableRefHistory (t * testing.T ) {
725+ const live = "cccccccccccccccccccccccccccccccccccccccc"
726+ compareURL := `repos/actions/checkout/compare/` + parityTestSHA + `\.\.\.` + live
727+ tests := []struct {
728+ name string
729+ compare httpmock.Responder
730+ // want is the finding category, "" for a clean pass.
731+ want string
732+ }{
733+ {"impostor blocks" , httpmock .JSONResponse (map [string ]any {"status" : "diverged" , "merge_base_commit" : map [string ]any {"sha" : parityTestMoved }}), "unreachable-pin" },
734+ {"moved forward passes" , httpmock .JSONResponse (map [string ]any {"status" : "ahead" , "merge_base_commit" : map [string ]any {"sha" : parityTestSHA }}), "" },
735+ {"compare 503 is inconclusive" , httpmock .StatusResponse (http .StatusServiceUnavailable ), "reachability-unknown" },
736+ }
737+ for _ , tt := range tests {
738+ for _ , proxy := range []bool {false , true } {
739+ t .Run (tt .name + "/" + map [bool ]string {false : "graphql" , true : "dependabot proxy" }[proxy ], func (t * testing.T ) {
740+ reg := & httpmock.Registry {}
741+ if proxy {
742+ t .Setenv ("GH_ACTIONS_LOCK_DEPENDABOT_PROXY" , "1" )
743+ useDefaultTransport (t , reg )
744+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout$` ), httpmock .JSONResponse (map [string ]any {
745+ "full_name" : "actions/checkout" , "id" : 1 , "owner" : map [string ]any {"id" : 1 },
746+ }))
747+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout/commits/` + parityTestSHA + `$` ), httpmock .JSONResponse (map [string ]any {"sha" : parityTestSHA }))
748+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout/commits/v4$` ), httpmock .JSONResponse (map [string ]any {"sha" : live }))
749+ } else {
750+ reg .Register (httpmock .GraphQL (`head: object\(expression` ), httpmock .JSONResponse (map [string ]any {
751+ "data" : map [string ]any {"a0" : map [string ]any {
752+ "nameWithOwner" : "actions/checkout" ,
753+ "commit" : map [string ]any {"oid" : parityTestSHA },
754+ "head" : map [string ]any {"oid" : live },
755+ }},
756+ }))
757+ }
758+ for range 10 {
759+ reg .Register (httpmock .REST ("GET" , compareURL ), tt .compare )
760+ }
761+ path := writeTempWorkflow (t , `
762+ name: ci
763+ on: push
764+ jobs:
765+ test:
766+ runs-on: ubuntu-latest
767+ steps:
768+ - uses: actions/checkout@v4
769+ ` , "actions/checkout@v4=sha1-" + parityTestSHA )
770+
771+ stdout , stderr , err := runCommandWithHTTP (t , reg , "--verify" , "--json=valid,findings" , path )
772+ if proxy && tt .want == "reachability-unknown" {
773+ require .Error (t , err , stderr )
774+ assert .NotErrorIs (t , err , errSilent , "inconclusive under the proxy is a retry" )
775+ assert .Contains (t , err .Error (), "HTTP 503" )
776+ return
777+ }
778+ var p parityPayload
779+ require .NoError (t , json .Unmarshal ([]byte (stdout ), & p ), stdout + stderr )
780+ switch tt .want {
781+ case "" :
782+ require .NoError (t , err , stderr )
783+ assert .Empty (t , p .Findings , "a moved mutable ref stays sticky and silent" )
784+ case "unreachable-pin" :
785+ require .ErrorIs (t , err , errSilent )
786+ require .Len (t , p .Findings , 1 )
787+ assert .Equal (t , "unreachable-pin" , p .Findings [0 ].Category )
788+ assert .Equal (t , "error" , p .Findings [0 ].Severity )
789+ assert .Contains (t , p .Findings [0 ].Detail , "is not in actions/checkout's history" )
790+ assert .Contains (t , p .Findings [0 ].Detail , "v4 currently points at cccccccccccc" )
791+ assert .NotEmpty (t , p .Findings [0 ].DocURL , "the impostor keeps its doc link" )
792+ default :
793+ require .NoError (t , err , stderr )
794+ require .Len (t , p .Findings , 1 )
795+ assert .Equal (t , tt .want , p .Findings [0 ].Category )
796+ assert .Equal (t , "warning" , p .Findings [0 ].Severity )
797+ }
798+ })
799+ }
800+ }
801+
802+ for _ , proxy := range []bool {false , true } {
803+ t .Run ("plain run makes no extra calls/" + map [bool ]string {false : "graphql" , true : "dependabot proxy" }[proxy ], func (t * testing.T ) {
804+ reg := & httpmock.Registry {}
805+ if proxy {
806+ t .Setenv ("GH_ACTIONS_LOCK_DEPENDABOT_PROXY" , "1" )
807+ useDefaultTransport (t , reg )
808+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout$` ), httpmock .JSONResponse (map [string ]any {
809+ "full_name" : "actions/checkout" , "id" : 1 , "owner" : map [string ]any {"id" : 1 },
810+ }))
811+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout/commits/` + parityTestSHA + `$` ), httpmock .JSONResponse (map [string ]any {"sha" : parityTestSHA }))
812+ } else {
813+ reg .Register (httpmock .GraphQL (`head: object\(expression` ), func (* http.Request ) (* http.Response , error ) {
814+ t .Error ("a plain run must not resolve mutable refs" )
815+ return nil , io .EOF
816+ })
817+ registerParity (reg )
818+ }
819+ path := writeTempWorkflow (t , `
820+ name: ci
821+ on: push
822+ jobs:
823+ test:
824+ runs-on: ubuntu-latest
825+ steps:
826+ - uses: actions/checkout@v4
827+ ` , "actions/checkout@v4=sha1-" + parityTestSHA )
828+
829+ _ , stderr , err := runCommandWithHTTP (t , reg , "--no-onboard" , "--no-narrow" , "--no-interactive" , path )
830+ require .NoError (t , err , stderr )
831+ for _ , req := range reg .Requests {
832+ assert .NotContains (t , req .URL .Path , "/compare/" )
833+ assert .NotContains (t , req .URL .Path , "/commits/v4" )
834+ }
835+ })
836+ }
837+ }
838+
839+ // TestParity_VerifyMissingMutableRefBlocks: under --verify a mutable ref
840+ // that no longer exists is a definitive answer the runner rejects, so it
841+ // blocks rather than passing on the commit alone.
842+ func TestParity_VerifyMissingMutableRefBlocks (t * testing.T ) {
843+ for _ , proxy := range []bool {false , true } {
844+ t .Run (map [bool ]string {false : "graphql" , true : "dependabot proxy" }[proxy ], func (t * testing.T ) {
845+ reg := & httpmock.Registry {}
846+ if proxy {
847+ t .Setenv ("GH_ACTIONS_LOCK_DEPENDABOT_PROXY" , "1" )
848+ useDefaultTransport (t , reg )
849+ }
850+ // One pass for --json, one for the terminal.
851+ for range 2 {
852+ if proxy {
853+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout$` ), httpmock .JSONResponse (map [string ]any {
854+ "full_name" : "actions/checkout" , "id" : 1 , "owner" : map [string ]any {"id" : 1 },
855+ }))
856+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout/commits/` + parityTestSHA + `$` ), httpmock .JSONResponse (map [string ]any {"sha" : parityTestSHA }))
857+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout/commits/v4$` ), httpmock .StatusResponse (http .StatusNotFound ))
858+ } else {
859+ reg .Register (httpmock .GraphQL (`head: object\(expression` ), httpmock .JSONResponse (map [string ]any {
860+ "data" : map [string ]any {"a0" : map [string ]any {
861+ "nameWithOwner" : "actions/checkout" ,
862+ "commit" : map [string ]any {"oid" : parityTestSHA },
863+ "head" : nil ,
864+ }},
865+ }))
866+ }
867+ }
868+ path := writeTempWorkflow (t , `
869+ name: ci
870+ on: push
871+ jobs:
872+ test:
873+ runs-on: ubuntu-latest
874+ steps:
875+ - uses: actions/checkout@v4
876+ ` , "actions/checkout@v4=sha1-" + parityTestSHA )
877+
878+ stdout , stderr , err := runCommandWithHTTP (t , reg , "--verify" , "--json=valid,findings" , path )
879+ require .ErrorIs (t , err , errSilent , stderr )
880+ var p parityPayload
881+ require .NoError (t , json .Unmarshal ([]byte (stdout ), & p ), stdout + stderr )
882+ require .Len (t , p .Findings , 1 )
883+ assert .Equal (t , "unreachable-pin" , p .Findings [0 ].Category )
884+ assert .Equal (t , "error" , p .Findings [0 ].Severity )
885+ assert .Equal (t , "ref v4 no longer exists in actions/checkout; the runner will reject this pin" , p .Findings [0 ].Detail )
886+ assert .Equal (t , "point `uses:` at a tag or branch that exists, then run `gh actions-lock`" , p .Findings [0 ].Remediation )
887+ assert .Empty (t , p .Findings [0 ].DocURL , "the hardening guide is unrelated to a missing ref" )
888+
889+ stdout , stderr , err = runCommandWithHTTP (t , reg , "--verify" , path )
890+ require .ErrorIs (t , err , errSilent )
891+ term := stdout + stderr
892+ assert .Contains (t , term , "Ref no longer exists actions/checkout@v4" )
893+ assert .Contains (t , term , "ref v4 no longer exists in actions/checkout; the runner will reject this pin" )
894+ assert .Contains (t , term , "point `uses:` at a tag or branch that exists, then run `gh actions-lock`" )
895+ assert .NotContains (t , term , "not in upstream history" )
896+ assert .NotContains (t , term , "/releases" )
897+ assert .NotContains (t , term , "see:" )
898+ })
899+ }
900+ }
901+
902+ // TestParity_VerifyRefCaseSensitive: refs differing only in case are
903+ // distinct refs to the runner, so each gets its own history check.
904+ func TestParity_VerifyRefCaseSensitive (t * testing.T ) {
905+ for _ , proxy := range []bool {false , true } {
906+ t .Run (map [bool ]string {false : "graphql" , true : "dependabot proxy" }[proxy ], func (t * testing.T ) {
907+ reg := & httpmock.Registry {}
908+ var heads []string
909+ if proxy {
910+ t .Setenv ("GH_ACTIONS_LOCK_DEPENDABOT_PROXY" , "1" )
911+ useDefaultTransport (t , reg )
912+ for range 2 {
913+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout$` ), httpmock .JSONResponse (map [string ]any {
914+ "full_name" : "actions/checkout" , "id" : 1 , "owner" : map [string ]any {"id" : 1 },
915+ }))
916+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout/commits/` + parityTestSHA + `$` ), httpmock .JSONResponse (map [string ]any {"sha" : parityTestSHA }))
917+ }
918+ for _ , ref := range []string {"Main" , "main" } {
919+ reg .Register (httpmock .REST ("GET" , `^/repos/actions/checkout/commits/` + ref + `$` ), func (req * http.Request ) (* http.Response , error ) {
920+ heads = append (heads , ref )
921+ return httpmock .JSONResponse (map [string ]any {"sha" : parityTestSHA })(req )
922+ })
923+ }
924+ } else {
925+ reg .Register (httpmock .GraphQL (`head: object\(expression` ), func (req * http.Request ) (* http.Response , error ) {
926+ body , _ := io .ReadAll (req .Body )
927+ for _ , ref := range []string {"Main" , "main" } {
928+ if strings .Contains (string (body ), `"` + ref + `^{commit}"` ) {
929+ heads = append (heads , ref )
930+ }
931+ }
932+ repo := map [string ]any {"nameWithOwner" : "actions/checkout" , "commit" : map [string ]any {"oid" : parityTestSHA }, "head" : map [string ]any {"oid" : parityTestSHA }}
933+ return httpmock .JSONResponse (map [string ]any {"data" : map [string ]any {"a0" : repo , "a1" : repo }})(req )
934+ })
935+ }
936+ path := writeTempWorkflow (t , `
937+ name: ci
938+ on: push
939+ jobs:
940+ test:
941+ runs-on: ubuntu-latest
942+ steps:
943+ - uses: actions/checkout@Main
944+ - uses: actions/checkout@main
945+ ` , "actions/checkout@Main=sha1-" + parityTestSHA , "actions/checkout@main=sha1-" + parityTestSHA )
946+
947+ _ , stderr , err := runCommandWithHTTP (t , reg , "--verify" , path )
948+ require .NoError (t , err , stderr )
949+ assert .ElementsMatch (t , []string {"Main" , "main" }, heads )
950+ })
951+ }
952+ }
0 commit comments