From 6ddc6ff04f5719fc8f3b452fa01d36417855c9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Thu, 17 Sep 2026 22:10:28 +0200 Subject: [PATCH] Fix streaming upload configuration and bounded recovery --- README.md | 8 +- includes/class-spacefast-client.php | 45 ++++++++++- includes/class-spacefast-plugin.php | 17 ++-- .../class-spacefast-simply-static-task.php | 6 +- includes/class-spacefast-static-publisher.php | 46 +++++++++-- includes/class-spacefast-sync-state.php | 5 +- readme.txt | 7 +- spacefast-wordpress.php | 4 +- tests/acceptance/upload-server.php | 32 ++++++++ tests/acceptance/upload.php | 80 +++++++++++++++++++ tests/acceptance/wordpress.php | 19 ++++- tests/behavior.php | 42 ++++++++-- 12 files changed, 278 insertions(+), 33 deletions(-) create mode 100644 tests/acceptance/upload-server.php create mode 100644 tests/acceptance/upload.php diff --git a/README.md b/README.md index 3402c40..40de533 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,13 @@ are additive so they cannot accidentally delete omitted site files. After a deletion, the plugin blocks partial publishing until a full export removes old files safely. Upload transfer is paged and processed one file per Simply Static background step so a large upload does not become one -unbounded WordPress request. +unbounded WordPress request. Transient upload failures retry the same file up to +three times, with delays of 2, 4, and 8 seconds between attempts. Streaming +uploads use the WordPress CA bundle, the `http_request_args` certificate and +timeout settings, and the configured WordPress proxy. TLS verification stays +enabled. Permanent failures stop with a recovery action; sanitized file, host, +error-code, and timing details appear under **Technical details**. Signed URLs +and authorization headers are never included in these diagnostics. Simply Static remains responsible for crawling WordPress and rewriting URLs. Spacefast never reads drafts, WordPress credentials, or the database. Dynamic diff --git a/includes/class-spacefast-client.php b/includes/class-spacefast-client.php index a83295b..372e364 100644 --- a/includes/class-spacefast-client.php +++ b/includes/class-spacefast-client.php @@ -404,6 +404,9 @@ private function curl_upload( string $url, string $method, array $headers, strin if ( ( 'https' !== $scheme && ! $development ) || ( ! $validated && ! $development ) ) { return self::upload_error( false, 'unsafe_upload_url', 'Spacefast returned an unsafe upload URL.' ); } + if ( ( new WP_Http() )->block_request( $url ) ) { + return self::upload_error( false, 'upload_blocked', 'WordPress blocks this upload destination. Ask your host to allow the Spacefast upload host, then retry publishing.' ); + } $size = filesize( $file_path ); $stream = fopen( $file_path, 'rb' ); if ( false === $size || false === $stream ) { @@ -423,6 +426,16 @@ private function curl_upload( string $url, string $method, array $headers, strin $curl_headers[] = 'Content-Type: ' . self::content_type_for_file( $file_path ); } $curl_headers[] = 'Content-Length: ' . $size; + $http_args = apply_filters( 'http_request_args', array( + 'method' => $method, + 'headers' => $headers, + 'body' => null, + 'redirection' => 0, + 'blocking' => true, + 'timeout' => 120, + 'sslverify' => true, + 'sslcertificates' => ABSPATH . WPINC . '/certificates/ca-bundle.crt', + ), $url ); $handle = curl_init( $url ); curl_setopt_array( $handle, @@ -438,19 +451,43 @@ private function curl_upload( string $url, string $method, array $headers, strin ? CURLPROTO_HTTP | CURLPROTO_HTTPS : CURLPROTO_HTTPS, CURLOPT_CONNECTTIMEOUT => 10, - CURLOPT_TIMEOUT => 120, + CURLOPT_TIMEOUT => max( 1, min( 120, (int) $http_args['timeout'] ) ), + CURLOPT_CAINFO => $http_args['sslcertificates'], + CURLOPT_USERAGENT => 'Spacefast-WordPress/' . SPACEFAST_WORDPRESS_VERSION, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ), ); + $proxy = new WP_HTTP_Proxy(); + if ( $proxy->is_enabled() && $proxy->send_through_proxy( $url ) ) { + curl_setopt( $handle, CURLOPT_PROXY, $proxy->host() ); + curl_setopt( $handle, CURLOPT_PROXYPORT, $proxy->port() ); + if ( $proxy->use_authentication() ) { + curl_setopt( $handle, CURLOPT_PROXYAUTH, CURLAUTH_ANY ); + curl_setopt( $handle, CURLOPT_PROXYUSERPWD, $proxy->authentication() ); + } + } $result = curl_exec( $handle ); $status = (int) curl_getinfo( $handle, CURLINFO_RESPONSE_CODE ); - $error = curl_error( $handle ); + $errno = curl_errno( $handle ); + $duration_ms = (int) round( curl_getinfo( $handle, CURLINFO_TOTAL_TIME ) * 1000 ); curl_close( $handle ); fclose( $stream ); if ( false === $result ) { - unset( $error ); - return self::upload_error( true, 'network_error', 'The generated file could not be uploaded.' ); + $retryable = in_array( $errno, array( + CURLE_COULDNT_RESOLVE_PROXY, CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT, + CURLE_OPERATION_TIMEDOUT, CURLE_SEND_ERROR, CURLE_RECV_ERROR, CURLE_GOT_NOTHING, CURLE_PARTIAL_FILE, + ), true ); + $failure = self::upload_error( + $retryable, + 'upload_curl_' . $errno, + in_array( $errno, array( CURLE_SSL_CACERT, CURLE_SSL_CACERT_BADFILE ), true ) + ? 'The upload certificate could not be verified. Ask your host to check the PHP certificate configuration, then retry publishing.' + : 'The file upload did not finish. Retry publishing. If it fails again, share the upload details below with your host.' + ); + // curl_error can contain signed URLs or proxy credentials. The error-code description cannot. + $failure['data'] = array( 'curlCode' => $errno, 'reason' => curl_strerror( $errno ), 'host' => $host, 'durationMs' => $duration_ms ); + return $failure; } return self::upload_response( $status ); } diff --git a/includes/class-spacefast-plugin.php b/includes/class-spacefast-plugin.php index c1d35a8..b82e88a 100644 --- a/includes/class-spacefast-plugin.php +++ b/includes/class-spacefast-plugin.php @@ -951,11 +951,12 @@ static function ( array $state ): array { ); } - public static function static_publish_progress( string $status ): void { + public static function static_publish_progress( string $status, string $message = '' ): void { if ( ! in_array( $status, array( 'uploading', 'finalizing' ), true ) ) return; Spacefast_Sync_State::mutate( - static function ( array $state ) use ( $status ): array { + static function ( array $state ) use ( $status, $message ): array { $state['last_status'] = $status; + $state['last_message'] = $message; $state['last_attempt_at'] = time(); return $state; } @@ -1162,14 +1163,14 @@ public static function render_admin(): void {
-

+

·

-

-

+

+

-

+

- +
@@ -1187,7 +1188,7 @@ public static function render_admin(): void {

-

+

diff --git a/includes/class-spacefast-simply-static-task.php b/includes/class-spacefast-simply-static-task.php index 769bbd8..6230d78 100644 --- a/includes/class-spacefast-simply-static-task.php +++ b/includes/class-spacefast-simply-static-task.php @@ -29,16 +29,16 @@ public function perform() { $partial_export ? 'additive' : 'snapshot' ); if ( ! $result['done'] ) { - Spacefast_Plugin::static_publish_progress( $result['status'] ); + Spacefast_Plugin::static_publish_progress( $result['status'], $result['message'] ?? '' ); $this->save_status_message( - 'finalizing' === $result['status'] + $result['message'] ?? ( 'finalizing' === $result['status'] ? __( 'Spacefast is activating the uploaded version', 'spacefast-wordpress' ) : sprintf( /* translators: 1: uploaded files, 2: files requested by Spacefast. */ __( 'Publishing to Spacefast: %1$d of %2$d files', 'spacefast-wordpress' ), $result['uploaded'], $result['total'] - ) + ) ) ); return false; } diff --git a/includes/class-spacefast-static-publisher.php b/includes/class-spacefast-static-publisher.php index d21e9c5..c178b77 100644 --- a/includes/class-spacefast-static-publisher.php +++ b/includes/class-spacefast-static-publisher.php @@ -8,16 +8,18 @@ final class Spacefast_Static_Publisher { /** * Advance one bounded step of a Simply Static publish. * - * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string} + * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string,message?:string} */ public static function step( string $archive_dir, ?Spacefast_Client $client = null, - string $publish_mode = 'snapshot' + string $publish_mode = 'snapshot', + ?int $now = null ): array { if ( ! in_array( $publish_mode, array( 'additive', 'snapshot' ), true ) ) { throw new InvalidArgumentException( 'Static publish mode is invalid.' ); } + $now = $now ?? time(); $root = self::archive_root( $archive_dir ); $client = $client ?? new Spacefast_Client(); $state = get_option( self::OPTION, array() ); @@ -89,6 +91,9 @@ public static function step( $next_target = max( 0, (int) ( $state['next_target'] ?? 0 ) ); if ( isset( $targets[ $next_target ] ) && is_array( $targets[ $next_target ] ) ) { + if ( (int) ( $state['retry_at'] ?? 0 ) > $now ) { + return self::retry_progress( $state ); + } $target = $targets[ $next_target ]; $path = self::target_file( $root, (string) ( $target['path'] ?? '' ) ); $result = $client->upload_static_file( $target, $path ); @@ -96,8 +101,27 @@ public static function step( if ( in_array( $result['code'], array( 'upload_http_401', 'upload_http_403' ), true ) ) { return self::resume( $state, $client ); } + Spacefast_Sync_State::mutate( + static function ( array $sync ) use ( $result, $target, $state ): array { + $sync['upload_diagnostic'] = array_merge( $result['data'], array( + 'code' => $result['code'], + 'file' => $target['path'], + 'host' => (string) wp_parse_url( $target['url'], PHP_URL_HOST ), + 'versionId' => $state['version_id'], + ) ); + return $sync; + } + ); + $attempt = (int) ( $state['upload_attempts'] ?? 0 ) + 1; + if ( $result['retryable'] && $attempt < 4 ) { + $state['upload_attempts'] = $attempt; + $state['retry_at'] = $now + 2 ** $attempt; + update_option( self::OPTION, $state, false ); + return self::retry_progress( $state ); + } throw new RuntimeException( $result['message'] ); } + unset( $state['upload_attempts'], $state['retry_at'] ); $state['next_target'] = $next_target + 1; $state['uploaded'] = (int) ( $state['uploaded'] ?? 0 ) + 1; update_option( self::OPTION, $state, false ); @@ -118,6 +142,18 @@ public static function step( return self::begin_finalizing( $state ); } + /** @param array $state */ + private static function retry_progress( array $state ): array { + return array( + 'done' => false, + 'version_id' => $state['version_id'], + 'uploaded' => $state['uploaded'], + 'total' => $state['total'], + 'status' => 'uploading', + 'message' => 'Upload interrupted. Retrying automatically from the last completed file.', + ); + } + /** * @return array */ @@ -203,7 +239,7 @@ private static function target_file( string $root, string $path ): string { /** * @param array $state State. - * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string} + * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string,message?:string} */ private static function resume( array $state, Spacefast_Client $client ): array { $pages = (int) ( $state['pages'] ?? 0 ) + 1; @@ -236,7 +272,7 @@ private static function resume( array $state, Spacefast_Client $client ): array /** * @param array $state State. - * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string} + * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string,message?:string} */ private static function begin_finalizing( array $state ): array { $state['phase'] = 'finalizing'; @@ -254,7 +290,7 @@ private static function begin_finalizing( array $state ): array { /** * @param array $state State. - * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string} + * @return array{done:bool,version_id:string,uploaded:int,total:int,status:string,message?:string} */ private static function poll_version( array $state, Spacefast_Client $client ): array { $polls = (int) ( $state['polls'] ?? 0 ) + 1; diff --git a/includes/class-spacefast-sync-state.php b/includes/class-spacefast-sync-state.php index 3a2d516..41b1c03 100644 --- a/includes/class-spacefast-sync-state.php +++ b/includes/class-spacefast-sync-state.php @@ -7,7 +7,7 @@ final class Spacefast_Sync_State { const HOOK = 'spacefast_wordpress_deliver'; /** - * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,last_build_id:string,last_version_id:string,last_change_at:int,last_attempt_at:int,last_success_at:int,last_settings_sync_at:int,active_generation:int,settings_pending:bool} + * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,upload_diagnostic:?array,last_build_id:string,last_version_id:string,last_change_at:int,last_attempt_at:int,last_success_at:int,last_settings_sync_at:int,active_generation:int,settings_pending:bool} */ public static function defaults(): array { return array( @@ -19,6 +19,7 @@ public static function defaults(): array { 'next_at' => 0, 'last_status' => 'idle', 'last_message' => '', + 'upload_diagnostic' => null, 'last_build_id' => '', 'last_version_id' => '', 'last_change_at' => 0, @@ -31,7 +32,7 @@ public static function defaults(): array { } /** - * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,last_build_id:string,last_version_id:string,last_change_at:int,last_attempt_at:int,last_success_at:int,last_settings_sync_at:int,active_generation:int,settings_pending:bool} + * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,upload_diagnostic:?array,last_build_id:string,last_version_id:string,last_change_at:int,last_attempt_at:int,last_success_at:int,last_settings_sync_at:int,active_generation:int,settings_pending:bool} */ public static function get(): array { $value = get_option( self::OPTION, array() ); diff --git a/readme.txt b/readme.txt index 021bb43..5bf8994 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: spacefast Tags: static site, headless cms, simply static, deployment Requires at least: 6.5 Requires PHP: 8.1 -Stable tag: 0.5.7 +Stable tag: 0.5.8 License: GPLv2 or later Publish a Simply Static export to Spacefast, or rebuild a headless site when WordPress content changes. @@ -31,6 +31,11 @@ OAuth access is limited to the Team you authorize and the mode you choose. WordP == Changelog == += 0.5.8 = +* Use WordPress certificate and proxy settings for streaming uploads. +* Retry transient file uploads with bounded backoff and preserve safe diagnostic details. +* Show the actual first-publish state and only offer the live site after successful publishing. + = 0.5.7 = * Include public custom post type archive routes in Simply Static exports. diff --git a/spacefast-wordpress.php b/spacefast-wordpress.php index 0b65d4d..8b775fd 100644 --- a/spacefast-wordpress.php +++ b/spacefast-wordpress.php @@ -3,7 +3,7 @@ * Plugin Name: Spacefast * Plugin URI: https://spacefast.com/ * Description: Publishes static WordPress exports or rebuilds a headless Spacefast site. - * Version: 0.5.7 + * Version: 0.5.8 * Update URI: https://github.com/spacefast/wordpress * Requires at least: 6.5 * Requires PHP: 8.1 @@ -14,7 +14,7 @@ defined( 'ABSPATH' ) || exit; -define( 'SPACEFAST_WORDPRESS_VERSION', '0.5.7' ); +define( 'SPACEFAST_WORDPRESS_VERSION', '0.5.8' ); define( 'SPACEFAST_WORDPRESS_FILE', __FILE__ ); require_once __DIR__ . '/includes/class-spacefast-settings.php'; diff --git a/tests/acceptance/upload-server.php b/tests/acceptance/upload-server.php new file mode 100644 index 0000000..3ac56fa --- /dev/null +++ b/tests/acceptance/upload-server.php @@ -0,0 +1,32 @@ + array( 'local_cert' => $certificate, 'verify_peer' => false ) ) ); +$server = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $error, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context ); +if ( false === $server ) throw new RuntimeException( $error ); +fwrite( STDOUT, stream_socket_get_name( $server, false ) . "\n" ); +fflush( STDOUT ); +$connection = stream_socket_accept( $server, 15 ); +if ( false === $connection ) exit( 1 ); +stream_set_timeout( $connection, 10 ); +if ( 'http' !== $certificate && ! @stream_socket_enable_crypto( $connection, true, STREAM_CRYPTO_METHOD_TLS_SERVER ) ) { + fclose( $connection ); + fclose( $server ); + exit( 0 ); +} +$headers = ''; +while ( false !== ( $line = fgets( $connection ) ) && "\r\n" !== $line ) $headers .= $line; +preg_match( '/Content-Length:\s*(\d+)/i', $headers, $length ); +if ( stripos( $headers, 'Expect: 100-continue' ) !== false ) fwrite( $connection, "HTTP/1.1 100 Continue\r\n\r\n" ); +$body = ''; +$remaining = (int) ( $length[1] ?? 0 ); +while ( $remaining > 0 ) { + $chunk = fread( $connection, min( 65536, $remaining ) ); + if ( false === $chunk || '' === $chunk ) exit( 2 ); + $body .= $chunk; + $remaining -= strlen( $chunk ); +} +fwrite( $connection, "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n" ); +fclose( $connection ); +fclose( $server ); +fwrite( STDOUT, json_encode( array( 'sha256' => hash( 'sha256', $body ), 'request' => strtok( $headers, "\r\n" ) ) ) . "\n" ); diff --git a/tests/acceptance/upload.php b/tests/acceptance/upload.php new file mode 100644 index 0000000..1f72469 --- /dev/null +++ b/tests/acceptance/upload.php @@ -0,0 +1,80 @@ + $fixture_dir . '/openssl.cnf', 'private_key_bits' => 2048, 'digest_alg' => 'sha256' ); +$key = openssl_pkey_new( $config ); +$csr = openssl_csr_new( array( 'commonName' => 'localhost' ), $key, $config ); +$cert = openssl_csr_sign( $csr, null, $key, 1, $config ); +openssl_x509_export( $cert, $cert_pem ); +openssl_pkey_export( $key, $key_pem ); +file_put_contents( $fixture_dir . '/ca.pem', $cert_pem ); +file_put_contents( $fixture_dir . '/server.pem', $cert_pem . $key_pem ); +$file = $fixture_dir . '/asset.bin'; +$bytes = random_bytes( 256 * 1024 ); +file_put_contents( $file, $bytes ); + +function spacefast_receiver( string $certificate ): array { + $process = proc_open( array( PHP_BINARY, __DIR__ . '/upload-server.php', $certificate ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes ); + if ( ! is_resource( $process ) ) throw new RuntimeException( 'Receiver could not start.' ); + stream_set_timeout( $pipes[1], 15 ); + $address = trim( (string) fgets( $pipes[1] ) ); + spacefast_accept( 1 === preg_match( '/^127\.0\.0\.1:\d+$/', $address ), 'Receiver did not reserve a port.' ); + return array( $process, $pipes, $address ); +} +function spacefast_receiver_finish( $process, array $pipes ): array { + $receipt = json_decode( (string) stream_get_contents( $pipes[1] ), true ); + $errors = stream_get_contents( $pipes[2] ); + foreach ( $pipes as $pipe ) fclose( $pipe ); + spacefast_accept( 0 === proc_close( $process ), 'Upload receiver failed: ' . $errors ); + return $receipt ?? array(); +} + +$allow_host = static fn( $external, $host ) => '127.0.0.1' === $host || $external; +$allow_port = static fn( $ports, $host, $url ) => array_merge( $ports, array( (int) wp_parse_url( $url, PHP_URL_PORT ) ) ); +add_filter( 'http_request_host_is_external', $allow_host, 10, 2 ); +add_filter( 'http_allowed_safe_ports', $allow_port, 10, 3 ); +$client = new Spacefast_Client(); +try { + list( $process, $pipes, $address ) = spacefast_receiver( $fixture_dir . '/server.pem' ); + $target = array( 'url' => 'https://' . $address . '/upload?secret=never-display', 'method' => 'PUT', 'headers' => array( 'Authorization' => 'Bearer never-display' ) ); + $failed = $client->upload_static_file( $target, $file ); + spacefast_receiver_finish( $process, $pipes ); + spacefast_accept( false === $failed['ok'] && false === $failed['retryable'], 'Untrusted TLS must fail without disabling verification.' ); + spacefast_accept( 60 === $failed['data']['curlCode'], 'Certificate failure lost its cURL code.' ); + spacefast_accept( '127.0.0.1' === $failed['data']['host'] && isset( $failed['data']['durationMs'] ), 'Transport diagnostics lack host or duration.' ); + spacefast_accept( ! str_contains( wp_json_encode( $failed ), 'never-display' ) && ! str_contains( wp_json_encode( $failed ), $fixture_dir ), 'Upload diagnostics expose a credential or local path.' ); + + $trust = static function ( array $args ) use ( $fixture_dir ): array { + $args['sslcertificates'] = $fixture_dir . '/ca.pem'; + return $args; + }; + add_filter( 'http_request_args', $trust ); + try { + list( $process, $pipes, $address ) = spacefast_receiver( $fixture_dir . '/server.pem' ); + $target['url'] = 'https://' . $address . '/upload?secret=never-display'; + $uploaded = $client->upload_static_file( $target, $file ); + $receipt = spacefast_receiver_finish( $process, $pipes ); + spacefast_accept( true === $uploaded['ok'], 'Streaming upload did not honor WordPress certificate configuration: ' . wp_json_encode( $uploaded ) ); + spacefast_accept( hash( 'sha256', $bytes ) === $receipt['sha256'], 'TLS upload changed or truncated bytes.' ); + } finally { + remove_filter( 'http_request_args', $trust ); + } + + list( $process, $pipes, $address ) = spacefast_receiver( 'http' ); + define( 'WP_PROXY_HOST', '127.0.0.1' ); + define( 'WP_PROXY_PORT', (int) substr( $address, strrpos( $address, ':' ) + 1 ) ); + $target['url'] = 'http://unresolvable.sf.localhost/upload'; + $uploaded = $client->upload_static_file( $target, $file ); + $receipt = spacefast_receiver_finish( $process, $pipes ); + spacefast_accept( true === $uploaded['ok'], 'Streaming upload bypassed the WordPress proxy.' ); + spacefast_accept( hash( 'sha256', $bytes ) === $receipt['sha256'], 'Proxy upload changed the file bytes.' ); + spacefast_accept( 'PUT http://unresolvable.sf.localhost/upload HTTP/1.1' === $receipt['request'], 'The receiver was not used as an HTTP proxy.' ); +} finally { + remove_filter( 'http_request_host_is_external', $allow_host ); + remove_filter( 'http_allowed_safe_ports', $allow_port ); + foreach ( glob( $fixture_dir . '/*' ) as $path ) unlink( $path ); + rmdir( $fixture_dir ); +} +fwrite( STDOUT, "Spacefast real TLS and proxy upload acceptance: PASS\n" ); diff --git a/tests/acceptance/wordpress.php b/tests/acceptance/wordpress.php index dc620bd..a97dc2f 100644 --- a/tests/acceptance/wordpress.php +++ b/tests/acceptance/wordpress.php @@ -102,6 +102,23 @@ function spacefast_accept( bool $condition, string $message ): void { spacefast_accept( str_contains( $connected_markup, 'Open live site' ), 'Connected UI does not expose the live result.' ); spacefast_accept( str_contains( $connected_markup, 'Technical details' ), 'Connected UI cannot disclose its receipt on demand.' ); +Spacefast_Settings::merge( array( 'mode' => Spacefast_Settings::MODE_STATIC, 'verified_at' => 0 ) ); +Spacefast_Plugin::static_publish_failed( 'The file upload did not finish. Retry publishing.' ); +ob_start(); +Spacefast_Plugin::render_admin(); +$failed_markup = (string) ob_get_clean(); +spacefast_accept( str_contains( $failed_markup, 'Needs attention' ), 'First publish hides the failed stage.' ); +spacefast_accept( ! str_contains( $failed_markup, 'Open live site' ), 'An unpublished site is offered as live.' ); +spacefast_accept( ! str_contains( $failed_markup, 'Changes are waiting.' ), 'A failed upload is described as queued progress.' ); +spacefast_accept( str_contains( $failed_markup, 'Retry publish' ), 'A failed publish has no recovery action.' ); +Spacefast_Plugin::static_publish_completed( 'ver_acceptance', 'live' ); +ob_start(); +Spacefast_Plugin::render_admin(); +$live_markup = (string) ob_get_clean(); +spacefast_accept( str_contains( $live_markup, 'Open live site' ), 'Successful first publish does not expose the live page.' ); + +require __DIR__ . '/upload.php'; + Spacefast_Settings::merge( array( 'mode' => Spacefast_Settings::MODE_STATIC ) ); $change_recorded = new ReflectionProperty( Spacefast_Plugin::class, 'change_recorded' ); $change_recorded->setValue( null, false ); @@ -113,7 +130,7 @@ function spacefast_accept( bool $condition, string $message ): void { ); $plugin = get_plugin_data( WP_PLUGIN_DIR . '/spacefast-wordpress/spacefast-wordpress.php', false, false ); -spacefast_accept( '0.5.7' === $plugin['Version'], 'Unexpected plugin version.' ); +spacefast_accept( '0.5.8' === $plugin['Version'], 'Unexpected plugin version.' ); spacefast_accept( 'https://github.com/spacefast/wordpress' === $plugin['UpdateURI'], 'Update URI is missing.' ); Spacefast_Settings::disconnect(); diff --git a/tests/behavior.php b/tests/behavior.php index 8cd742c..d86feef 100644 --- a/tests/behavior.php +++ b/tests/behavior.php @@ -45,8 +45,8 @@ function add_action( string $hook, $callback, int $priority = 10, int $accepted_ function add_filter( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): void { $GLOBALS['spacefast_hooks'][ $hook ] = array( $callback, $priority, $accepted_args ); } -function wp_parse_url( string $value ) { - return parse_url( $value ); +function wp_parse_url( string $value, int $component = -1 ) { + return parse_url( $value, $component ); } function wp_check_filetype( string $path ): array { return array( @@ -913,6 +913,7 @@ static function ( string $url ) use ( &$invalid_receipt_requests ): array { ); $static_requests = array(); +$upload_status = 503; $static_client = new Spacefast_Client( static function ( string $url, array $args ) use ( &$static_requests ): array { $static_requests[] = array( $url, $args ); @@ -970,12 +971,18 @@ static function ( string $url, array $args ) use ( &$static_requests ): array { ) ); }, - static function ( string $url, string $method, array $headers, string $file ) use ( &$static_requests ): array { + static function ( string $url, string $method, array $headers, string $file ) use ( &$static_requests, &$upload_status ): array { $static_requests[] = array( $url, $method, $headers, $file ); - return response( 204 ); + return response( $upload_status ); } ); -$publish_step = Spacefast_Static_Publisher::step( $archive, $static_client ); +$publish_step = Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', 100 ); +check( 0 === $publish_step['uploaded'], 'a failed upload does not advance the cursor' ); +check( 'Upload interrupted. Retrying automatically from the last completed file.' === $publish_step['message'], 'transient errors keep the background export running' ); +$upload_status = 204; +$waiting = Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', 101 ); +check( 0 === $waiting['uploaded'], 'backoff prevents uploading before the retry deadline' ); +$publish_step = Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', 102 ); check( false === $publish_step['done'], 'uploads a bounded target per background step' ); check( 1 === $publish_step['uploaded'], 'records completed generated-file uploads' ); $publish_done = Spacefast_Static_Publisher::step( $archive, $static_client ); @@ -1004,10 +1011,33 @@ static function ( string $url, string $method, array $headers, string $file ) us 'uploads the generated public-access declaration with the export' ); check( - str_ends_with( $static_requests[3][3], '/assets/app.js' ), + str_ends_with( $static_requests[4][3], '/assets/app.js' ), 'uploads the next opaque target after resuming' ); +Spacefast_Static_Publisher::reset(); +$upload_status = 503; +foreach ( array( 200, 202, 206 ) as $retry_time ) { + $retry = Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', $retry_time ); + check( 0 === $retry['uploaded'], 'repeated failure preserves upload progress' ); +} +try { + Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', 214 ); + throw new LogicException( 'Upload retries did not stop.' ); +} catch ( RuntimeException $error ) { + check( 'Spacefast rejected a generated file upload.' === $error->getMessage(), 'retry exhaustion reports the upload failure' ); +} +Spacefast_Static_Publisher::reset(); +check( 'upload_http_503' === Spacefast_Sync_State::get()['upload_diagnostic']['code'], 'upload evidence survives exporter cleanup' ); +$upload_status = 422; +try { + Spacefast_Static_Publisher::step( $archive, $static_client, 'snapshot', 300 ); + throw new LogicException( 'Permanent rejection was retried.' ); +} catch ( RuntimeException $error ) { + check( 'Spacefast rejected a generated file upload.' === $error->getMessage(), 'permanent rejection fails immediately' ); +} +Spacefast_Static_Publisher::reset(); + Spacefast_Sync_State::save( array_merge( Spacefast_Sync_State::defaults(), array( 'desired' => 2, 'active_generation' => 1 ) ) );