@@ -1906,6 +1906,39 @@ test('macOS release verification uses the pinned workflow revision without chang
19061906 assert . equal ( readFileSync ( artifact , 'utf8' ) , 'signed immutable fixture' ) ;
19071907} ) ;
19081908
1909+ test ( 'failed Mac verification retains a tar archive without publishing it' , ( t ) => {
1910+ if ( process . platform === 'win32' ) { t . skip ( 'Mac archive mode checks run on Unix' ) ; return ; }
1911+ const workflow = yaml . parse ( readFileSync ( path . join ( repoRoot , '.github/workflows/desktop-package.yml' ) , 'utf8' ) ) ;
1912+ const steps = workflow . jobs . package . steps ;
1913+ const verify = steps . find ( ( step ) => step . name === 'Verify Apple signature and notarization' ) ;
1914+ const preserve = steps . find ( ( step ) => step . name === 'Preserve macOS verification recovery bundles' ) ;
1915+ const upload = steps . find ( ( step ) => step . name === 'Upload macOS verification recovery bundles' ) ;
1916+ assert . equal ( verify . id , 'apple_verification' ) ;
1917+ assert . equal ( verify [ 'continue-on-error' ] , undefined ) ;
1918+ for ( const step of [ preserve , upload ] ) {
1919+ for ( const failed of [ true , false ] ) for ( const os of [ 'macOS' , 'Linux' , 'Windows' ] ) for ( const outcome of [ 'success' , 'failure' ] ) {
1920+ const enabled = Function ( 'failure' , 'runner' , 'steps' , `return (${ step . if } );` ) ( ( ) => failed , { os } , { apple_verification : { outcome } } ) ;
1921+ assert . equal ( enabled , failed && os === 'macOS' && outcome === 'failure' ) ;
1922+ }
1923+ }
1924+ assert . match ( upload . with . name , / - v e r i f i c a t i o n - r e c o v e r y $ / ) ;
1925+ assert . doesNotMatch ( upload . with . name , / - b u n d l e $ / ) ;
1926+ const cwd = mkdtempSync ( path . join ( tmpdir ( ) , 'openbitfun-notary-archive-' ) ) ;
1927+ t . after ( ( ) => rmSync ( cwd , { recursive : true , force : true } ) ) ;
1928+ const app = path . join ( cwd , 'target/fixture/release/bundle/macos/Test.app' ) ;
1929+ mkdirSync ( app , { recursive : true } ) ;
1930+ writeFileSync ( path . join ( app , 'tool' ) , 'signed executable fixture' , { mode : 0o755 } ) ;
1931+ assert . equal ( spawnSync ( 'ln' , [ '-s' , 'tool' , path . join ( app , 'tool-link' ) ] , { windowsHide : true } ) . status , 0 ) ;
1932+ const archived = spawnSync ( 'bash' , [ '-c' , preserve . run . replaceAll ( '${{ matrix.platform.target }}' , 'fixture' ) ] , { cwd, encoding : 'utf8' , windowsHide : true } ) ;
1933+ assert . equal ( archived . status , 0 , archived . stderr ) ;
1934+ mkdirSync ( path . join ( cwd , 'restored' ) ) ;
1935+ assert . equal ( spawnSync ( 'tar' , [ '-xzf' , 'macos-verification-recovery.tar.gz' , '-C' , 'restored' ] , { cwd, windowsHide : true } ) . status , 0 ) ;
1936+ const restored = path . join ( cwd , 'restored/bundle/macos/Test.app' ) ;
1937+ assert . equal ( readFileSync ( path . join ( restored , 'tool-link' ) , 'utf8' ) , 'signed executable fixture' ) ;
1938+ assert . equal ( spawnSync ( 'bash' , [ '-c' , 'test -x "$1"' , 'fixture' , path . join ( restored , 'tool' ) ] , { windowsHide : true } ) . status , 0 ) ;
1939+ assert . equal ( spawnSync ( 'readlink' , [ path . join ( restored , 'tool-link' ) ] , { encoding : 'utf8' , windowsHide : true } ) . stdout . trim ( ) , 'tool' ) ;
1940+ } ) ;
1941+
19091942for ( const scenario of [
19101943 { name : 'accepts a transient app ticket connection loss' , failures : 2 , network : true , attempts : 3 , status : 0 , submissions : 1 } ,
19111944 { name : 'retries DMG stapling without resubmitting notarization' , action : 'staple' , kind : 'dmg' , failures : 1 , network : true , attempts : 2 , status : 0 , submissions : 1 } ,
@@ -1916,6 +1949,14 @@ for (const scenario of [
19161949 { name : 'rejects signature errors before contacting Apple' , signatureStatus : 1 , attempts : 0 , status : 1 , submissions : 0 } ,
19171950 { name : 'preserves Gatekeeper rejection' , gatekeeperStatus : 3 , attempts : 1 , status : 3 , submissions : 0 } ,
19181951 { name : 'preserves notarization submission rejection' , notaryStatus : 4 , attempts : 1 , status : 4 , submissions : 1 } ,
1952+ { name : 'resumes an uploaded DMG after transient offline polling' , waitFailures : 2 , waitCode : - 1009 , waitAttempts : 3 , attempts : 1 , status : 0 , submissions : 1 } ,
1953+ { name : 'resumes polling after a lost connection' , waitFailures : 1 , waitCode : - 1005 , waitAttempts : 2 , attempts : 1 , status : 0 , submissions : 1 } ,
1954+ { name : 'retains a receipt after exhausted polling retries' , waitFailures : 99 , waitCode : - 1009 , waitAttempts : 3 , attempts : 1 , status : 1 , submissions : 1 , resume : true } ,
1955+ { name : 'does not retry unrelated polling failures' , waitFailures : 99 , waitCode : - 1001 , waitAttempts : 1 , attempts : 1 , status : 1 , submissions : 1 } ,
1956+ { name : 'rejects a missing submission ID without retrying upload' , submissionId : 'invalid' , waitAttempts : 0 , attempts : 1 , status : 1 , submissions : 1 } ,
1957+ { name : 'rejects a response for another submission' , responseId : '22222222-2222-4222-8222-222222222222' , waitAttempts : 1 , attempts : 1 , status : 1 , submissions : 1 } ,
1958+ { name : 'rejects an Invalid notarization response' , waitStatus : 'Invalid' , waitAttempts : 1 , attempts : 1 , status : 1 , submissions : 1 } ,
1959+
19191960] ) {
19201961 test ( `macOS verification ${ scenario . name } ` , ( t ) => {
19211962 const cwd = mkdtempSync ( path . join ( tmpdir ( ) , 'openbitfun-apple-ticket-' ) ) ;
@@ -1939,7 +1980,23 @@ for (const scenario of [
19391980 sleep() { printf 'sleep %s\\n' "$*" >> "$FIXTURE_CALLS"; }
19401981 xcrun() {
19411982 printf 'xcrun %s\\n' "$*" >> "$FIXTURE_CALLS"
1942- if [[ "$1" == notarytool ]]; then return "$NOTARY_STATUS"; fi
1983+ if [[ "$1" == notarytool ]]; then
1984+ if [[ "$2" == submit ]]; then
1985+ [[ "$NOTARY_STATUS" == 0 ]] || return "$NOTARY_STATUS"
1986+ printf '{"id":"%s"}\\n' "$SUBMISSION_ID"
1987+ else
1988+ local waits=0
1989+ [[ ! -f waits ]] || read -r waits < waits
1990+ waits=$((waits + 1))
1991+ printf '%s\\n' "$waits" > waits
1992+ if [[ "$waits" -le "$WAIT_FAILURES" ]]; then
1993+ printf 'Error Domain=NSURLErrorDomain Code=%s "Network error"\\n' "$WAIT_CODE" >&2
1994+ return 1
1995+ fi
1996+ printf '{"id":"%s","status":"%s"}\\n' "$RESPONSE_ID" "$WAIT_STATUS"
1997+ fi
1998+ return 0
1999+ fi
19432000 local kind=app count=0
19442001 [[ "$3" == *.dmg ]] && kind=dmg
19452002 if [[ "$2" == "$TICKET_ACTION" && "$kind" == "$TICKET_KIND" ]]; then
@@ -1962,6 +2019,8 @@ for (const scenario of [
19622019 ` ] , { cwd, encoding : 'utf8' , windowsHide : true , env : { ...process . env ,
19632020 OPENBITFUN_APPLE_SIGNING_CONFIGURED : 'true' , FIXTURE_CALLS : path . join ( cwd , 'calls' ) ,
19642021 SIGNATURE_STATUS : String ( scenario . signatureStatus ?? 0 ) , GATEKEEPER_STATUS : String ( scenario . gatekeeperStatus ?? 0 ) ,
2022+ SUBMISSION_ID : scenario . submissionId ?? '11111111-1111-4111-8111-111111111111' , RESPONSE_ID : scenario . responseId ?? '11111111-1111-4111-8111-111111111111' ,
2023+ WAIT_FAILURES : String ( scenario . waitFailures ?? 0 ) , WAIT_CODE : String ( scenario . waitCode ?? - 1009 ) , WAIT_STATUS : scenario . waitStatus ?? 'Accepted' ,
19652024 NOTARY_STATUS : String ( scenario . notaryStatus ?? 0 ) , APPLE_API_ISSUER : 'fixture' , APPLE_API_KEY : 'fixture' , APPLE_API_KEY_PATH : 'fixture' ,
19662025 TICKET_ACTION : scenario . action ?? 'validate' , TICKET_KIND : scenario . kind ?? 'app' ,
19672026 TICKET_FAILURES : String ( scenario . failures ?? 0 ) , TICKET_NETWORK : String ( scenario . network ?? false ) ,
@@ -1973,7 +2032,38 @@ for (const scenario of [
19732032 assert . equal ( ( calls . match ( / ^ m i c r o p h o n e $ / gm) ?? [ ] ) . length , scenario . signatureStatus ? 0 : 1 , calls ) ;
19742033 const attempts = scenario . attempts === 0 ? 0 : Number ( readFileSync ( path . join ( cwd , 'attempts' ) , 'utf8' ) ) ;
19752034 assert . equal ( attempts , scenario . attempts , calls ) ;
1976- assert . equal ( ( calls . match ( / ^ s l e e p / gm) ?? [ ] ) . length , Math . max ( 0 , scenario . attempts - 1 ) , calls ) ;
2035+ assert . equal ( ( calls . match ( / ^ s l e e p / gm) ?? [ ] ) . length , Math . max ( 0 , scenario . attempts - 1 ) + Math . max ( 0 , ( scenario . waitAttempts ?? 1 ) - 1 ) , calls ) ;
2036+ assert . equal ( ( calls . match ( / ^ x c r u n n o t a r y t o o l w a i t / gm) ?? [ ] ) . length ,
2037+ scenario . waitAttempts ?? ( scenario . notaryStatus ? 0 : scenario . submissions ) , calls ) ;
2038+ if ( scenario . resume ) {
2039+ const receipt = path . join ( cwd , 'target/fixture/release/bundle/dmg/Test App.dmg.notary-submission.json' ) ;
2040+ assert . equal ( JSON . parse ( readFileSync ( receipt , 'utf8' ) ) . id , '11111111-1111-4111-8111-111111111111' ) ;
2041+ const retryScript = `
2042+ codesign() { printf 'Authority=Developer ID Application: Fixture\\nflags=0x10000(runtime)\\nTimestamp=fixture\\n'; }
2043+ file() { printf 'Mach-O fixture\\n'; }
2044+ spctl() { return 0; }
2045+ xcrun() {
2046+ printf '%s\\n' "$*" >> resumed
2047+ if [[ "$1 $2" == 'notarytool submit' ]]; then return 99; fi
2048+ if [[ "$1 $2" == 'notarytool wait' ]]; then
2049+ printf '{"id":"11111111-1111-4111-8111-111111111111","status":"Accepted"}\\n'
2050+ fi
2051+ }
2052+ export -f codesign file spctl xcrun
2053+ bash scripts/ci/verify-macos-signing.sh fixture
2054+ ` ;
2055+ const retryOptions = { cwd, encoding : 'utf8' , windowsHide : true , env : { ...process . env ,
2056+ OPENBITFUN_APPLE_SIGNING_CONFIGURED : 'true' , FIXTURE_CALLS : path . join ( cwd , 'calls' ) ,
2057+ APPLE_API_ISSUER : 'fixture' , APPLE_API_KEY : 'fixture' , APPLE_API_KEY_PATH : 'fixture' } } ;
2058+ const retry = spawnSync ( 'bash' , [ '-c' , retryScript ] , retryOptions ) ;
2059+ assert . equal ( retry . status , 0 , retry . stderr ) ;
2060+ assert . doesNotMatch ( readFileSync ( path . join ( cwd , 'resumed' ) , 'utf8' ) , / n o t a r y t o o l s u b m i t / ) ;
2061+ writeFileSync ( path . join ( cwd , 'target/fixture/release/bundle/dmg/Test App.dmg' ) , 'changed after receipt' ) ;
2062+ const changed = spawnSync ( 'bash' , [ '-c' , retryScript ] , retryOptions ) ;
2063+ assert . notEqual ( changed . status , 0 ) ;
2064+ assert . match ( changed . stderr , / r e c e i p t d o e s n o t m a t c h / ) ;
2065+
2066+ }
19772067 if ( scenario . status === 0 ) assert . match ( result . stdout , / V e r i f i e d D e v e l o p e r I D s i g n a t u r e s / ) ;
19782068 else assert . doesNotMatch ( result . stdout , / V e r i f i e d D e v e l o p e r I D s i g n a t u r e s / ) ;
19792069 } ) ;
0 commit comments