diff --git a/composer.json b/composer.json index 7af7114..44c3d60 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,11 @@ "wp-phpunit/wp-phpunit": "^7.0", "wpackagist-plugin/woocommerce": "^11.0" }, + "autoload": { + "psr-4": { + "LibreSign\\WordPressTheme\\": "src/" + } + }, "autoload-dev": { "psr-4": { "LibreSign\\WordPressTheme\\Tests\\": "tests/" @@ -55,8 +60,10 @@ "cs:fix": "phpcbf", "stan": "phpstan analyse --memory-limit=2G", "test": [ + "@test:unit", "@test:integration" ], + "test:unit": "phpunit", "test:integration": "phpunit -c phpunit-integration.xml.dist", "ci": [ "@lint", diff --git a/inc/cpf-cnpj.php b/inc/cpf-cnpj.php index 7ba0266..31dee7e 100644 --- a/inc/cpf-cnpj.php +++ b/inc/cpf-cnpj.php @@ -10,8 +10,12 @@ * @package libresign */ +use LibreSign\WordPressTheme\TaxId; + defined( 'ABSPATH' ) || exit; +require_once dirname( __DIR__ ) . '/src/TaxId.php'; + /** * Register the CPF/CNPJ field in the checkout address section. */ @@ -35,89 +39,6 @@ function libresign_theme_register_cpf_cnpj_field() { } add_action( 'woocommerce_init', 'libresign_theme_register_cpf_cnpj_field' ); -/** - * Validate CPF — 11-digit Brazilian individual taxpayer ID. - * - * Uses the standard Módulo 11 algorithm with decreasing weights 10..2 / 11..2. - * - * @param string $cpf Raw or formatted CPF (dots and dash are stripped). - * @return bool - */ -function libresign_theme_validate_cpf( string $cpf ): bool { - $cpf = preg_replace( '/[^0-9]/', '', $cpf ); - if ( strlen( $cpf ) !== 11 ) { - return false; - } - // All identical digits (e.g. 000.000.000-00) are never valid. - if ( preg_match( '/^(\d)\1{10}$/', $cpf ) ) { - return false; - } - - $mod11 = function ( string $str, int $start_weight ): int { - $sum = 0; - $w = $start_weight; - for ( $i = 0; $i < strlen( $str ); $i++ ) { - $sum += (int) $str[ $i ] * $w--; - } - $rem = $sum % 11; - return $rem < 2 ? 0 : 11 - $rem; - }; - - $dv1 = $mod11( substr( $cpf, 0, 9 ), 10 ); - if ( $dv1 !== (int) $cpf[9] ) { - return false; - } - return $mod11( substr( $cpf, 0, 10 ), 11 ) === (int) $cpf[10]; -} - -/** - * Validate CNPJ — 14-character Brazilian company taxpayer ID. - * - * Handles both the legacy numeric format and the new alphanumeric format - * introduced by Instrução Normativa RFB nº 2.119/2022 (Anexo Único): - * - Positions 1–12: alphanumeric (root 8 chars + order 4 chars) - * - Positions 13–14: numeric check digits - * - * Algorithm: Módulo 11 with weights 2–9 assigned right-to-left, cycling - * back to 2 after reaching 9. Character value = ord(char) − 48, which gives - * the digit value for '0'–'9' (ASCII 48–57) and 17–42 for 'A'–'Z' - * (ASCII 65–90), exactly as specified in the Receita Federal table. - * - * @param string $cnpj Raw or formatted CNPJ (dots, slash, dash are stripped). - * @return bool - */ -function libresign_theme_validate_cnpj( string $cnpj ): bool { - $cnpj = strtoupper( preg_replace( '/[\.\-\/]/', '', $cnpj ) ); - if ( strlen( $cnpj ) !== 14 ) { - return false; - } - // All identical digits — applies to legacy numeric CNPJs. - if ( preg_match( '/^(\d)\1{13}$/', $cnpj ) ) { - return false; - } - // Positions 1–12: uppercase letters or digits; positions 13–14: digits. - if ( ! preg_match( '/^[A-Z0-9]{12}\d{2}$/', $cnpj ) ) { - return false; - } - - $mod11 = function ( string $str ): int { - $sum = 0; - $w = 2; - for ( $i = strlen( $str ) - 1; $i >= 0; $i-- ) { - $sum += ( ord( $str[ $i ] ) - 48 ) * $w; - $w = 9 === $w ? 2 : $w + 1; - } - $rem = $sum % 11; - return $rem < 2 ? 0 : 11 - $rem; - }; - - $dv1 = $mod11( substr( $cnpj, 0, 12 ) ); - if ( $dv1 !== (int) $cnpj[12] ) { - return false; - } - return $mod11( substr( $cnpj, 0, 13 ) ) === (int) $cnpj[13]; -} - /** * Server-side enforcement via the address-location validation hook. * @@ -135,25 +56,16 @@ function libresign_theme_validate_cnpj( string $cnpj ): bool { return; } - $value = isset( $fields['libresign/cpf-cnpj'] ) ? trim( (string) $fields['libresign/cpf-cnpj'] ) : ''; - if ( '' === $value ) { - $errors->add( 'libresign_cpf_cnpj_required', __( 'Please enter your CPF or CNPJ.', 'libresign' ) ); - return; - } - - $digits_only = preg_replace( '/[^0-9]/', '', $value ); - $stripped = strtoupper( preg_replace( '/[\.\-\/]/', '', $value ) ); + $messages = array( + 'libresign_cpf_cnpj_required' => __( 'Please enter your CPF or CNPJ.', 'libresign' ), + 'invalid_cnpj' => __( 'Please enter a valid CNPJ.', 'libresign' ), + 'invalid_cpf' => __( 'Please enter a valid CPF.', 'libresign' ), + 'invalid_cpf_cnpj' => __( 'Please enter a valid CPF (11 digits) or CNPJ (14 characters).', 'libresign' ), + ); - if ( 14 === strlen( $stripped ) ) { - if ( ! libresign_theme_validate_cnpj( $value ) ) { - $errors->add( 'invalid_cnpj', __( 'Please enter a valid CNPJ.', 'libresign' ) ); - } - } elseif ( 11 === strlen( $digits_only ) ) { - if ( ! libresign_theme_validate_cpf( $value ) ) { - $errors->add( 'invalid_cpf', __( 'Please enter a valid CPF.', 'libresign' ) ); - } - } else { - $errors->add( 'invalid_cpf_cnpj', __( 'Please enter a valid CPF (11 digits) or CNPJ (14 characters).', 'libresign' ) ); + $error_code = TaxId::error_code( isset( $fields['libresign/cpf-cnpj'] ) ? (string) $fields['libresign/cpf-cnpj'] : '' ); + if ( '' !== $error_code ) { + $errors->add( $error_code, $messages[ $error_code ] ); } }, 10, 3 ); @@ -190,7 +102,7 @@ function libresign_theme_strip_irrelevant_cpf_cnpj( $wc_object ) { * 1. Visibility — hides the field for non-BR customers and reveals it when * the billing country is Brazil, surviving React re-renders via a * MutationObserver. - * 2. Validation — mirrors the PHP Módulo 11 algorithms above so the user + * 2. Validation — mirrors the Módulo 11 algorithms of src/TaxId.php so the user * gets native WooCommerce Blocks error feedback (red border + message * below the field) without a server round-trip. A re-entrancy guard * prevents the wp.data subscriber from triggering itself in an infinite @@ -249,7 +161,7 @@ function updateVisibility() { } // ----------------------------------------------------------------------- - // Módulo 11 validation — mirrors the PHP functions in this file. + // Módulo 11 validation — mirrors src/TaxId.php. // ----------------------------------------------------------------------- /** diff --git a/inc/footer-fragment.php b/inc/footer-fragment.php index 749f217..c93a3c8 100644 --- a/inc/footer-fragment.php +++ b/inc/footer-fragment.php @@ -7,9 +7,11 @@ * only the GitHub deploy webhook triggers a background refresh. */ +use LibreSign\WordPressTheme\SiteFragment; + defined( 'ABSPATH' ) || exit; -const LIBRESIGN_THEME_SITE_FRAGMENT_DEFAULT_LOCALE_KEY = 'default'; +require_once dirname( __DIR__ ) . '/src/SiteFragment.php'; /** * Return the supported fragment types. @@ -30,69 +32,6 @@ function libresign_theme_site_fragment_storage_directory_name( $fragment_type ) return 'header' === $fragment_type ? 'libresign-header' : 'libresign-footer'; } -/** - * Normalize a site origin string. - * - * @param string $site_origin Raw site origin. - * @return string - */ -function libresign_theme_site_fragment_normalize_site_origin( $site_origin ) { - return rtrim( trim( (string) $site_origin ), '/' ); -} - -/** - * Normalize a locale/tag into a path-friendly BCP47-style value. - * - * @param string $locale Raw locale/tag. - * @return string - */ -function libresign_theme_site_fragment_normalize_locale_tag( $locale ) { - $locale = trim( str_replace( '_', '-', (string) $locale ) ); - - if ( '' === $locale ) { - return ''; - } - - $parts = array_values( array_filter( explode( '-', $locale ), static fn ( $part ) => '' !== $part ) ); - if ( empty( $parts ) ) { - return ''; - } - - $parts[0] = strtolower( $parts[0] ); - - foreach ( $parts as $index => $part ) { - if ( 0 === $index ) { - continue; - } - - if ( 2 === strlen( $part ) || 3 === strlen( $part ) ) { - $parts[ $index ] = strtoupper( $part ); - continue; - } - - if ( 4 === strlen( $part ) ) { - $parts[ $index ] = ucfirst( strtolower( $part ) ); - continue; - } - - $parts[ $index ] = $part; - } - - return implode( '-', $parts ); -} - -/** - * Convert locale to storage key. - * - * @param string $locale Locale. - * @return string - */ -function libresign_theme_site_fragment_storage_key( $locale ) { - $locale = libresign_theme_site_fragment_normalize_locale_tag( $locale ); - - return '' === $locale ? LIBRESIGN_THEME_SITE_FRAGMENT_DEFAULT_LOCALE_KEY : $locale; -} - /** * Get the storage base dir for a fragment type. * @@ -117,28 +56,6 @@ function libresign_theme_site_fragment_storage_base_url( $fragment_type ) { return trailingslashit( $uploads['baseurl'] ) . libresign_theme_site_fragment_storage_directory_name( $fragment_type ); } -/** - * Build the published fragment URL. - * - * @param string $site_origin Static site origin. - * @param string $fragment_type Fragment type. - * @param string $locale Locale. - * @return string - */ -function libresign_theme_site_fragment_url( $site_origin, $fragment_type, $locale ) { - $site_origin = libresign_theme_site_fragment_normalize_site_origin( $site_origin ); - $locale = libresign_theme_site_fragment_normalize_locale_tag( $locale ); - $path = '/fragments/'; - - if ( '' !== $locale ) { - $path .= rawurlencode( $locale ) . '/'; - } - - $path .= $fragment_type; - - return $site_origin . $path; -} - /** * Fetch a remote URL and return the HTTP code/body. * @@ -198,50 +115,6 @@ function libresign_theme_site_fragment_is_optional_http_error( $error ) { return is_array( $data ) && isset( $data['status'] ) && 404 === (int) $data['status']; } -/** - * Extract CSS/JS fragment asset URLs from fragment HTML. - * - * @param string $html Fragment HTML. - * @return array|WP_Error - */ -function libresign_theme_site_fragment_extract_asset_urls( $html ) { - if ( - ! preg_match( '/\bdata-fragment-css=("|\')([^"\']+)\1/i', $html, $css_matches ) - || ! preg_match( '/\bdata-fragment-js=("|\')([^"\']+)\1/i', $html, $js_matches ) - ) { - return new WP_Error( - 'libresign_theme_site_fragment_missing_assets', - __( 'Fragment HTML is missing the expected CSS/JS references.', 'libresign' ) - ); - } - - return array( - 'css' => (string) $css_matches[2], - 'js' => (string) $js_matches[2], - ); -} - -/** - * Extract discovered locales from the default header fragment HTML. - * - * @param string $html Header fragment HTML. - * @return array - */ -function libresign_theme_site_fragment_extract_locales_from_header_html( $html ) { - $locales = array(); - - if ( preg_match_all( '~\/fragments(?:\/([^\/#?"\']+))?\/header(?:[\/#?"\']|$)~i', $html, $matches ) ) { - foreach ( $matches[1] as $locale ) { - $locale = libresign_theme_site_fragment_normalize_locale_tag( rawurldecode( (string) $locale ) ); - $locales[] = $locale; - } - } - - $locales[] = ''; - - return array_values( array_unique( array_filter( $locales, 'is_string' ) ) ); -} - /** * Build fallback locales from the local WordPress language context. * @@ -266,55 +139,11 @@ function libresign_theme_site_fragment_fallback_locales() { $candidates[] = get_locale(); $candidates[] = ''; - $candidates = array_map( 'libresign_theme_site_fragment_normalize_locale_tag', $candidates ); + $candidates = array_map( array( SiteFragment::class, 'language_tag' ), $candidates ); return array_values( array_unique( array_filter( $candidates, 'is_string' ) ) ); } -/** - * Remove fragment asset data attributes after syncing. - * - * @param string $html Fragment HTML. - * @return string - */ -function libresign_theme_site_fragment_strip_runtime_asset_attributes( $html ) { - $html = preg_replace( '/\s+data-fragment-css=("|\')[^"\']+\1/i', '', $html ) ?? $html; - $html = preg_replace( '/\s+data-fragment-js=("|\')[^"\']+\1/i', '', $html ) ?? $html; - - return $html; -} - -/** - * Rewrite root-relative URLs so stored artifacts continue pointing at the static site. - * - * @param string $content Content to rewrite. - * @param string $site_origin Static site origin. - * @return string - */ -function libresign_theme_site_fragment_rewrite_root_relative_urls( $content, $site_origin ) { - $site_origin = libresign_theme_site_fragment_normalize_site_origin( $site_origin ); - - $content = preg_replace_callback( - '/\b(href|src|action|poster)=("|\')(\/(?!\/)[^"\']*)\2/i', - static function ( $matches ) use ( $site_origin ) { - return $matches[1] . '=' . $matches[2] . $site_origin . $matches[3] . $matches[2]; - }, - $content - ) ?? $content; - - $content = preg_replace_callback( - '~url\(\s*(?:("|\')\s*)?(\/(?!\/)[^)"\']+)(?:\s*\1)?\s*\)~i', - static function ( $matches ) use ( $site_origin ) { - $quote = $matches[1]; - - return 'url(' . $quote . $site_origin . $matches[2] . $quote . ')'; - }, - $content - ) ?? $content; - - return $content; -} - /** * Collect all fragment artifacts from the static site. * @@ -324,7 +153,7 @@ static function ( $matches ) use ( $site_origin ) { * @return array|WP_Error */ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragment_types, $context = array() ) { - $site_origin = libresign_theme_site_fragment_normalize_site_origin( $site_origin ); + $site_origin = SiteFragment::origin( $site_origin ); if ( '' === $site_origin ) { return new WP_Error( 'libresign_theme_site_fragment_origin_missing', @@ -332,7 +161,7 @@ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragmen ); } - $default_header_url = libresign_theme_site_fragment_url( $site_origin, 'header', '' ); + $default_header_url = SiteFragment::url( $site_origin, 'header', '' ); $default_header_response = libresign_theme_site_fragment_fetch_url( $default_header_url ); if ( is_wp_error( $default_header_response ) ) { return $default_header_response; @@ -340,7 +169,7 @@ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragmen $locales = array_merge( array( '' ), - libresign_theme_site_fragment_extract_locales_from_header_html( (string) $default_header_response['body'] ), + SiteFragment::linked_locales( (string) $default_header_response['body'] ), libresign_theme_site_fragment_fallback_locales() ); $locales = array_values( array_unique( $locales ) ); @@ -353,7 +182,7 @@ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragmen $summary[ $fragment_type ] = array(); foreach ( $locales as $locale ) { - $fragment_url = libresign_theme_site_fragment_url( $site_origin, $fragment_type, $locale ); + $fragment_url = SiteFragment::url( $site_origin, $fragment_type, $locale ); if ( 'header' === $fragment_type && '' === $locale ) { $fragment_response = $default_header_response; @@ -369,13 +198,16 @@ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragmen return $fragment_response; } - $asset_urls = libresign_theme_site_fragment_extract_asset_urls( (string) $fragment_response['body'] ); - if ( is_wp_error( $asset_urls ) ) { + $asset_urls = SiteFragment::asset_urls( (string) $fragment_response['body'] ); + if ( null === $asset_urls ) { if ( '' !== $locale ) { continue; } - return $asset_urls; + return new WP_Error( + 'libresign_theme_site_fragment_missing_assets', + __( 'Fragment HTML is missing the expected CSS/JS references.', 'libresign' ) + ); } foreach ( array( 'css', 'js' ) as $asset_type ) { @@ -390,15 +222,15 @@ function libresign_theme_site_fragment_collect_artifacts( $site_origin, $fragmen } } - $html = libresign_theme_site_fragment_strip_runtime_asset_attributes( (string) $fragment_response['body'] ); - $html = libresign_theme_site_fragment_rewrite_root_relative_urls( $html, $site_origin ); - $css = libresign_theme_site_fragment_rewrite_root_relative_urls( (string) $asset_cache[ $asset_urls['css'] ], $site_origin ); - $js = libresign_theme_site_fragment_rewrite_root_relative_urls( (string) $asset_cache[ $asset_urls['js'] ], $site_origin ); + $html = SiteFragment::without_asset_attributes( (string) $fragment_response['body'] ); + $html = SiteFragment::with_absolute_urls( $html, $site_origin ); + $css = SiteFragment::with_absolute_urls( (string) $asset_cache[ $asset_urls['css'] ], $site_origin ); + $js = SiteFragment::with_absolute_urls( (string) $asset_cache[ $asset_urls['js'] ], $site_origin ); $artifact = array( 'fragment_type' => $fragment_type, - 'locale' => libresign_theme_site_fragment_normalize_locale_tag( $locale ), - 'locale_key' => libresign_theme_site_fragment_storage_key( $locale ), + 'locale' => SiteFragment::language_tag( $locale ), + 'locale_key' => SiteFragment::storage_key( $locale ), 'fragment_url' => $fragment_url, 'css_url' => (string) $asset_urls['css'], 'js_url' => (string) $asset_urls['js'], @@ -516,7 +348,7 @@ function libresign_theme_sync_site_fragments_from_origin( $site_origin, $fragmen } return array( - 'origin' => libresign_theme_site_fragment_normalize_site_origin( $site_origin ), + 'origin' => SiteFragment::origin( $site_origin ), 'synced' => $persisted, 'locales' => $collected['locales'], ); @@ -538,20 +370,7 @@ function libresign_theme_site_fragment_locale_lookup_keys() { $candidates[] = (string) determine_locale(); $candidates[] = (string) get_locale(); - $keys = array(); - foreach ( $candidates as $candidate ) { - $normalized = libresign_theme_site_fragment_normalize_locale_tag( $candidate ); - if ( '' === $normalized ) { - continue; - } - - $keys[] = $normalized; - $keys[] = strtolower( strtok( $normalized, '-' ) ); - } - - $keys[] = LIBRESIGN_THEME_SITE_FRAGMENT_DEFAULT_LOCALE_KEY; - - return array_values( array_unique( $keys ) ); + return SiteFragment::lookup_keys( $candidates ); } /** diff --git a/inc/github-site-webhook.php b/inc/github-site-webhook.php index b142a9c..b51b89c 100644 --- a/inc/github-site-webhook.php +++ b/inc/github-site-webhook.php @@ -3,8 +3,14 @@ * GitHub webhook receiver for production site fragment synchronization. */ +use LibreSign\WordPressTheme\SiteDeploy; +use LibreSign\WordPressTheme\SiteFragment; + defined( 'ABSPATH' ) || exit; +require_once dirname( __DIR__ ) . '/src/SiteDeploy.php'; +require_once dirname( __DIR__ ) . '/src/SiteFragment.php'; + const LIBRESIGN_THEME_GITHUB_SITE_WEBHOOK_NAMESPACE = 'libresign/v1'; const LIBRESIGN_THEME_GITHUB_SITE_WEBHOOK_ROUTE = '/site-deploy-webhook'; @@ -199,7 +205,7 @@ function libresign_theme_site_origin() { ); foreach ( $values as $value ) { - $value = libresign_theme_site_fragment_normalize_site_origin( $value ); + $value = SiteFragment::origin( $value ); if ( '' !== $value ) { return $value; } @@ -285,92 +291,6 @@ function libresign_theme_github_site_webhook_ignored_response( $data = array(), return $response; } -/** - * Verify the GitHub webhook HMAC signature. - * - * @param string $body Raw request body. - * @param string $signature Signature header value. - * @param string $secret Shared secret. - * @return bool - */ -function libresign_theme_verify_github_webhook_signature( $body, $signature, $secret ) { - $secret = trim( (string) $secret ); - $signature = trim( (string) $signature ); - - if ( '' === $body || '' === $secret || '' === $signature ) { - return false; - } - - if ( 0 === stripos( $signature, 'sha256=' ) ) { - $signature = substr( $signature, 7 ); - } - - if ( ! ctype_xdigit( $signature ) ) { - return false; - } - - $expected = hash_hmac( 'sha256', $body, $secret ); - - return hash_equals( $expected, strtolower( $signature ) ); -} - -/** - * Check whether the webhook user agent looks like GitHub Hookshot. - * - * @param string $user_agent User agent header. - * @return bool - */ -function libresign_theme_is_github_hookshot_user_agent( $user_agent ) { - return 0 === strpos( trim( (string) $user_agent ), 'GitHub-Hookshot/' ); -} - -/** - * Extract the workflow name from the payload. - * - * @param array $payload Parsed payload. - * @return string - */ -function libresign_theme_site_deploy_workflow_name_from_payload( $payload ) { - $workflow_run_name = isset( $payload['workflow_run']['name'] ) ? trim( (string) $payload['workflow_run']['name'] ) : ''; - if ( '' !== $workflow_run_name ) { - return $workflow_run_name; - } - - return isset( $payload['workflow']['name'] ) ? trim( (string) $payload['workflow']['name'] ) : ''; -} - -/** - * Determine whether the payload represents the production site deploy event. - * - * @param array $payload Parsed payload. - * @return bool - */ -function libresign_theme_is_production_site_deploy_workflow_run( $payload ) { - $repository = isset( $payload['repository']['full_name'] ) ? trim( (string) $payload['repository']['full_name'] ) : ''; - $action = isset( $payload['action'] ) ? trim( (string) $payload['action'] ) : ''; - $conclusion = isset( $payload['workflow_run']['conclusion'] ) ? trim( (string) $payload['workflow_run']['conclusion'] ) : ''; - $head_branch = isset( $payload['workflow_run']['head_branch'] ) ? trim( (string) $payload['workflow_run']['head_branch'] ) : ''; - $workflow_name = libresign_theme_site_deploy_workflow_name_from_payload( $payload ); - - if ( libresign_theme_site_deploy_repository_name() !== $repository ) { - return false; - } - - if ( 'completed' !== $action ) { - return false; - } - - if ( 'success' !== $conclusion ) { - return false; - } - - if ( libresign_theme_site_deploy_branch_name() !== $head_branch ) { - return false; - } - - return libresign_theme_site_deploy_workflow_name() === $workflow_name; -} - /** * Mark a webhook delivery as processed, returning false when duplicated. * @@ -429,7 +349,7 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { } $user_agent = (string) $request->get_header( 'user-agent' ); - if ( ! libresign_theme_is_github_hookshot_user_agent( $user_agent ) ) { + if ( ! SiteDeploy::is_github_delivery( $user_agent ) ) { return new WP_Error( 'libresign_theme_github_webhook_invalid_agent', __( 'The webhook request does not look like a GitHub delivery.', 'libresign' ), @@ -439,7 +359,7 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { $body = (string) $request->get_body(); $signature = (string) $request->get_header( 'x-hub-signature-256' ); - if ( ! libresign_theme_verify_github_webhook_signature( $body, $signature, $secret ) ) { + if ( ! SiteDeploy::signature_matches( $body, $signature, $secret ) ) { return new WP_Error( 'libresign_theme_github_webhook_invalid_signature', __( 'Invalid GitHub webhook signature.', 'libresign' ), @@ -476,7 +396,7 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { } // Deploy workflow started: post "starting" comment on the PR. - if ( libresign_theme_is_site_deploy_starting( $payload ) ) { + if ( SiteDeploy::is_deploy_starting( $payload, libresign_theme_site_deploy_repository_name() ) ) { $delivery_id = (string) $request->get_header( 'x-github-delivery' ); if ( ! libresign_theme_mark_github_delivery_once( $delivery_id ) ) { return libresign_theme_github_site_webhook_ignored_response( @@ -487,12 +407,18 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { return rest_ensure_response( array( 'status' => 'acknowledged', 'action' => 'deploy_starting' ) ); } - if ( ! libresign_theme_is_production_site_deploy_workflow_run( $payload ) ) { + $is_production_deploy = SiteDeploy::is_production_deploy( + $payload, + libresign_theme_site_deploy_repository_name(), + libresign_theme_site_deploy_branch_name(), + libresign_theme_site_deploy_workflow_name() + ); + if ( ! $is_production_deploy ) { return libresign_theme_github_site_webhook_ignored_response( array( 'reason' => 'not_production_deploy', 'repository' => isset( $payload['repository']['full_name'] ) ? (string) $payload['repository']['full_name'] : '', - 'workflow_name' => libresign_theme_site_deploy_workflow_name_from_payload( $payload ), + 'workflow_name' => SiteDeploy::workflow_name( $payload ), 'head_branch' => isset( $payload['workflow_run']['head_branch'] ) ? (string) $payload['workflow_run']['head_branch'] : '', 'conclusion' => isset( $payload['workflow_run']['conclusion'] ) ? (string) $payload['workflow_run']['conclusion'] : '', ) @@ -537,7 +463,7 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { array( 'delivery_id' => $delivery_id, 'repository' => libresign_theme_site_deploy_repository_name(), - 'workflow' => libresign_theme_site_deploy_workflow_name_from_payload( $payload ), + 'workflow' => SiteDeploy::workflow_name( $payload ), 'head_branch' => isset( $workflow_run['head_branch'] ) ? (string) $workflow_run['head_branch'] : '', 'source_sha' => isset( $workflow_run['head_sha'] ) ? (string) $workflow_run['head_sha'] : '', 'source_url' => isset( $workflow_run['html_url'] ) ? (string) $workflow_run['html_url'] : '', @@ -552,7 +478,7 @@ function libresign_theme_receive_github_site_deploy_webhook( $request ) { 'status' => 'synced', 'delivery_id' => $delivery_id, 'repository' => libresign_theme_site_deploy_repository_name(), - 'workflow' => libresign_theme_site_deploy_workflow_name_from_payload( $payload ), + 'workflow' => SiteDeploy::workflow_name( $payload ), 'origin' => $sync_result['origin'], 'synced' => $sync_result['synced'], ) @@ -612,16 +538,7 @@ function libresign_theme_post_pr_sync_comment( string $site_origin, array $sync_ return; } - $synced = $sync_result['synced'] ?? array(); - $header_list = implode( ', ', (array) ( $synced['header'] ?? array() ) ); - $footer_list = implode( ', ', (array) ( $synced['footer'] ?? array() ) ); - - $body = "✅ **Header and footer fragments updated in production!**\n\n" . - "| Fragment | Synced locales |\n" . - "|----------|----------------|\n" . - "| Header | `{$header_list}` |\n" . - "| Footer | `{$footer_list}` |\n\n" . - "Source: {$site_origin}"; + $body = SiteDeploy::sync_comment( $sync_result['synced'] ?? array(), $site_origin ); $parts = explode( '/', $repository, 2 ); if ( count( $parts ) !== 2 || '' === $parts[0] || '' === $parts[1] ) { @@ -643,24 +560,6 @@ function libresign_theme_post_pr_sync_comment( string $site_origin, array $sync_ ); } -/** - * Determine whether the payload represents the Deploy workflow starting on main. - * - * @param array $payload Parsed payload. - * @return bool - */ -function libresign_theme_is_site_deploy_starting( array $payload ): bool { - $repository = isset( $payload['repository']['full_name'] ) ? trim( (string) $payload['repository']['full_name'] ) : ''; - $action = isset( $payload['action'] ) ? trim( (string) $payload['action'] ) : ''; - $head_branch = isset( $payload['workflow_run']['head_branch'] ) ? trim( (string) $payload['workflow_run']['head_branch'] ) : ''; - $wf_name = libresign_theme_site_deploy_workflow_name_from_payload( $payload ); - - return libresign_theme_site_deploy_repository_name() === $repository - && 'in_progress' === $action - && 'main' === $head_branch - && 'Deploy' === $wf_name; -} - /** * Handle the Deploy workflow starting: find the merged PR and post a "starting" comment. * @@ -731,16 +630,5 @@ function libresign_theme_find_pr_for_commit( string $repository, string $sha, st return 0; } - $pulls = json_decode( wp_remote_retrieve_body( $response ), true ); - if ( ! is_array( $pulls ) ) { - return 0; - } - - foreach ( $pulls as $pr ) { - if ( ! empty( $pr['merged_at'] ) ) { - return (int) $pr['number']; - } - } - - return 0; + return SiteDeploy::merged_pull_request( json_decode( wp_remote_retrieve_body( $response ), true ) ); } \ No newline at end of file diff --git a/inc/logo.php b/inc/logo.php index 91a1775..1377832 100644 --- a/inc/logo.php +++ b/inc/logo.php @@ -14,8 +14,12 @@ * @package libresign */ +use LibreSign\WordPressTheme\Logo; + defined( 'ABSPATH' ) || exit; +require_once dirname( __DIR__ ) . '/src/Logo.php'; + /** * Return the LibreSign logo URL for the given display context. * @@ -41,31 +45,7 @@ function libresign_theme_get_theme_logo_url( string $variant = 'light' ): string * device pixel ratio. */ function libresign_theme_custom_logo_needs_fallback( $custom_logo_html ) { - if ( '' === trim( (string) $custom_logo_html ) ) { - return true; - } - - if ( ! preg_match( '/]+>/', (string) $custom_logo_html, $tag_matches ) ) { - return true; - } - - $img_tag = $tag_matches[0]; - - // Collect every image URL from src and srcset. - $urls = array(); - - if ( preg_match( '/src=["\']([^"\']+)["\']/', $img_tag, $m ) ) { - $urls[] = html_entity_decode( $m[1] ); - } - - if ( preg_match( '/srcset=["\']([^"\']+)["\']/', $img_tag, $m ) ) { - foreach ( explode( ',', html_entity_decode( $m[1] ) ) as $part ) { - $candidate = trim( explode( ' ', trim( $part ) )[0] ); - if ( $candidate ) { - $urls[] = $candidate; - } - } - } + $urls = Logo::image_urls( (string) $custom_logo_html ); if ( empty( $urls ) ) { return true; @@ -117,29 +97,10 @@ function libresign_theme_filter_custom_logo( $custom_logo_html, $blog_id ) { return $custom_logo_html; } - $logo_light = esc_url( libresign_theme_get_theme_logo_url( 'light' ) ); - $logo_dark = esc_url( libresign_theme_get_theme_logo_url( 'dark' ) ); - - // Remove srcset/sizes and set src to the light-background logo. - $patched = preg_replace( '/\ssrcset=["\'][^"\']*["\']/', '', $custom_logo_html ); - $patched = preg_replace( '/\ssizes=["\'][^"\']*["\']/', '', $patched ); - $patched = preg_replace( - '/(]+)src=["\'][^"\']*["\']/', - '$1src="' . $logo_light . '"', - $patched - ); - - // Wrap in for automatic dark/light switching. - $patched = preg_replace( - '/(]+>)/', - '' - . '' - . '' - . '$1' - . '', - $patched + return Logo::with_fallback( + (string) $custom_logo_html, + esc_url( libresign_theme_get_theme_logo_url( 'light' ) ), + esc_url( libresign_theme_get_theme_logo_url( 'dark' ) ) ); - - return $patched ?: $custom_logo_html; } add_filter( 'get_custom_logo', 'libresign_theme_filter_custom_logo', 10, 2 ); diff --git a/phpcs.xml.dist b/phpcs.xml.dist index d9effba..705cb92 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -36,6 +36,7 @@ + /woocommerce/* diff --git a/phpstan.neon.dist b/phpstan.neon.dist index f33ccd7..4480303 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,6 +8,7 @@ parameters: paths: - functions.php - inc + - src - patterns - woocommerce - tests diff --git a/phpunit-integration.xml.dist b/phpunit-integration.xml.dist index 36ace97..aaa3a56 100644 --- a/phpunit-integration.xml.dist +++ b/phpunit-integration.xml.dist @@ -17,6 +17,7 @@ functions.php inc + src woocommerce diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..2f71cdd --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,21 @@ + + + + + tests/Unit + + + + + src + + + diff --git a/src/Logo.php b/src/Logo.php new file mode 100644 index 0000000..4ca61ec --- /dev/null +++ b/src/Logo.php @@ -0,0 +1,48 @@ +]+>/', $html, $tag_matches ) ) { + return array(); + } + + $urls = array(); + + if ( preg_match( '/src=["\']([^"\']+)["\']/', $tag_matches[0], $matches ) ) { + $urls[] = html_entity_decode( $matches[1] ); + } + + if ( preg_match( '/srcset=["\']([^"\']+)["\']/', $tag_matches[0], $matches ) ) { + foreach ( explode( ',', html_entity_decode( $matches[1] ) ) as $candidate ) { + $url = trim( explode( ' ', trim( $candidate ) )[0] ); + if ( $url ) { + $urls[] = $url; + } + } + } + + return $urls; + } + + public static function with_fallback( string $html, string $light_logo_url, string $dark_logo_url ): string { + $patched = preg_replace( '/\ssrcset=["\'][^"\']*["\']/', '', $html ) ?? $html; + $patched = preg_replace( '/\ssizes=["\'][^"\']*["\']/', '', $patched ) ?? $patched; + $patched = preg_replace( '/(]+)src=["\'][^"\']*["\']/', '$1src="' . $light_logo_url . '"', $patched ) ?? $patched; + + return preg_replace( + '/(]+>)/', + '' + . '' + . '' + . '$1' + . '', + $patched + ) ?? $patched; + } +} diff --git a/src/SiteDeploy.php b/src/SiteDeploy.php new file mode 100644 index 0000000..ba75e35 --- /dev/null +++ b/src/SiteDeploy.php @@ -0,0 +1,101 @@ + $payload + */ + public static function workflow_name( array $payload ): string { + $run_name = self::text( $payload, 'workflow_run', 'name' ); + + return '' !== $run_name ? $run_name : self::text( $payload, 'workflow', 'name' ); + } + + /** + * @param array $payload + */ + public static function is_production_deploy( array $payload, string $repository, string $branch, string $workflow ): bool { + return self::text( $payload, 'repository', 'full_name' ) === $repository + && self::text( $payload, 'action' ) === 'completed' + && self::text( $payload, 'workflow_run', 'conclusion' ) === 'success' + && self::text( $payload, 'workflow_run', 'head_branch' ) === $branch + && self::workflow_name( $payload ) === $workflow; + } + + /** + * @param array $payload + */ + public static function is_deploy_starting( array $payload, string $repository ): bool { + return self::text( $payload, 'repository', 'full_name' ) === $repository + && self::text( $payload, 'action' ) === 'in_progress' + && self::text( $payload, 'workflow_run', 'head_branch' ) === 'main' + && self::workflow_name( $payload ) === 'Deploy'; + } + + public static function merged_pull_request( mixed $pull_requests ): int { + if ( ! is_array( $pull_requests ) ) { + return 0; + } + + foreach ( $pull_requests as $pull_request ) { + if ( ! empty( $pull_request['merged_at'] ) ) { + return (int) $pull_request['number']; + } + } + + return 0; + } + + /** + * @param array $synced + */ + public static function sync_comment( array $synced, string $origin ): string { + $header_locales = implode( ', ', (array) ( $synced['header'] ?? array() ) ); + $footer_locales = implode( ', ', (array) ( $synced['footer'] ?? array() ) ); + + return "✅ **Header and footer fragments updated in production!**\n\n" . + "| Fragment | Synced locales |\n" . + "|----------|----------------|\n" . + "| Header | `{$header_locales}` |\n" . + "| Footer | `{$footer_locales}` |\n\n" . + "Source: {$origin}"; + } + + /** + * @param array $payload + */ + private static function text( array $payload, string $key, string $subkey = '' ): string { + $value = $payload[ $key ] ?? ''; + + if ( '' !== $subkey ) { + $value = is_array( $value ) ? ( $value[ $subkey ] ?? '' ) : ''; + } + + return is_scalar( $value ) ? trim( (string) $value ) : ''; + } +} diff --git a/src/SiteFragment.php b/src/SiteFragment.php new file mode 100644 index 0000000..d9a4f2a --- /dev/null +++ b/src/SiteFragment.php @@ -0,0 +1,125 @@ + '' !== $part ) ); + if ( empty( $parts ) ) { + return ''; + } + + $parts[0] = strtolower( $parts[0] ); + + foreach ( $parts as $index => $part ) { + if ( 0 === $index ) { + continue; + } + + if ( 2 === strlen( $part ) || 3 === strlen( $part ) ) { + $parts[ $index ] = strtoupper( $part ); + } elseif ( 4 === strlen( $part ) ) { + $parts[ $index ] = ucfirst( strtolower( $part ) ); + } + } + + return implode( '-', $parts ); + } + + public static function storage_key( string $locale ): string { + $tag = self::language_tag( $locale ); + + return '' === $tag ? self::DEFAULT_LOCALE_KEY : $tag; + } + + public static function url( string $origin, string $fragment_type, string $locale ): string { + $tag = self::language_tag( $locale ); + + return self::origin( $origin ) . '/fragments/' . ( '' === $tag ? '' : rawurlencode( $tag ) . '/' ) . $fragment_type; + } + + /** + * @return array{css: string, js: string}|null + */ + public static function asset_urls( string $html ): ?array { + if ( + ! preg_match( '/\bdata-fragment-css=("|\')([^"\']+)\1/i', $html, $css_matches ) + || ! preg_match( '/\bdata-fragment-js=("|\')([^"\']+)\1/i', $html, $js_matches ) + ) { + return null; + } + + return array( + 'css' => $css_matches[2], + 'js' => $js_matches[2], + ); + } + + /** + * @return string[] + */ + public static function linked_locales( string $header_html ): array { + $locales = array(); + + if ( preg_match_all( '~\/fragments(?:\/([^\/#?"\']+))?\/header(?:[\/#?"\']|$)~i', $header_html, $matches ) ) { + foreach ( $matches[1] as $locale ) { + $locales[] = self::language_tag( rawurldecode( $locale ) ); + } + } + + $locales[] = ''; + + return array_values( array_unique( $locales ) ); + } + + /** + * @param string[] $candidates + * @return string[] + */ + public static function lookup_keys( array $candidates ): array { + $keys = array(); + + foreach ( $candidates as $candidate ) { + $tag = self::language_tag( $candidate ); + if ( '' === $tag ) { + continue; + } + + $keys[] = $tag; + $keys[] = strtolower( (string) strtok( $tag, '-' ) ); + } + + $keys[] = self::DEFAULT_LOCALE_KEY; + + return array_values( array_unique( $keys ) ); + } + + public static function without_asset_attributes( string $html ): string { + $html = preg_replace( '/\s+data-fragment-css=("|\')[^"\']+\1/i', '', $html ) ?? $html; + + return preg_replace( '/\s+data-fragment-js=("|\')[^"\']+\1/i', '', $html ) ?? $html; + } + + public static function with_absolute_urls( string $content, string $origin ): string { + $origin = self::origin( $origin ); + + $content = preg_replace_callback( + '/\b(href|src|action|poster)=("|\')(\/(?!\/)[^"\']*)\2/i', + static fn ( $matches ) => $matches[1] . '=' . $matches[2] . $origin . $matches[3] . $matches[2], + $content + ) ?? $content; + + return preg_replace_callback( + '~url\(\s*(?:("|\')\s*)?(\/(?!\/)[^)"\']+)(?:\s*\1)?\s*\)~i', + static fn ( $matches ) => 'url(' . $matches[1] . $origin . $matches[2] . $matches[1] . ')', + $content + ) ?? $content; + } +} diff --git a/src/TaxId.php b/src/TaxId.php new file mode 100644 index 0000000..8bacf4f --- /dev/null +++ b/src/TaxId.php @@ -0,0 +1,74 @@ += 0; $position-- ) { + $sum += ( ord( $characters[ $position ] ) - 48 ) * $weight; + $weight = 9 === $weight ? 2 : $weight + 1; + } + $remainder = $sum % 11; + + return $remainder < 2 ? 0 : 11 - $remainder; + }; + + return $check_digit( substr( $cnpj, 0, 12 ) ) === (int) $cnpj[12] + && $check_digit( substr( $cnpj, 0, 13 ) ) === (int) $cnpj[13]; + } + + private static function digits( string $value ): string { + return (string) preg_replace( '/[^0-9]/', '', $value ); + } + + private static function without_formatting( string $value ): string { + return strtoupper( (string) preg_replace( '/[\.\-\/]/', '', $value ) ); + } +} diff --git a/tests/Integration/Inc/CpfCnpjTest.php b/tests/Integration/Inc/CpfCnpjTest.php index b54db4e..6a8db00 100644 --- a/tests/Integration/Inc/CpfCnpjTest.php +++ b/tests/Integration/Inc/CpfCnpjTest.php @@ -12,46 +12,6 @@ final class CpfCnpjTest extends WP_UnitTestCase { - /** - * @dataProvider provide_cpfs - */ - public function test_validates_a_cpf( $cpf, $valid ) { - $this->assertSame( $valid, libresign_theme_validate_cpf( $cpf ) ); - } - - public static function provide_cpfs() { - yield 'digits only' => array( '52998224725', true ); - yield 'formatted' => array( '529.982.247-25', true ); - yield 'another valid one' => array( '123.456.789-09', true ); - yield 'wrong first check digit' => array( '529.982.247-35', false ); - yield 'wrong second check digit' => array( '529.982.247-24', false ); - yield 'every digit the same' => array( '111.111.111-11', false ); - yield 'too short' => array( '5299822472', false ); - yield 'too long' => array( '529982247250', false ); - yield 'empty' => array( '', false ); - } - - /** - * @dataProvider provide_cnpjs - */ - public function test_validates_a_cnpj( $cnpj, $valid ) { - $this->assertSame( $valid, libresign_theme_validate_cnpj( $cnpj ) ); - } - - public static function provide_cnpjs() { - yield 'digits only' => array( '11222333000181', true ); - yield 'formatted' => array( '11.222.333/0001-81', true ); - yield 'alphanumeric' => array( '12.ABC.345/01DE-35', true ); - yield 'alphanumeric in lowercase' => array( '12.abc.345/01de-35', true ); - yield 'wrong second check digit' => array( '11.222.333/0001-80', false ); - yield 'wrong alphanumeric check digit' => array( '12.ABC.345/01DE-34', false ); - yield 'letters in the check digits' => array( '12.ABC.345/01DE-3A', false ); - yield 'every digit the same' => array( '00.000.000/0000-00', false ); - yield 'symbols other than the formatting' => array( '12.ABC.345/01D*-35', false ); - yield 'too short' => array( '1122233300018', false ); - yield 'empty' => array( '', false ); - } - public function test_registers_the_field_in_the_address_form() { __internal_woocommerce_blocks_deregister_checkout_field( 'libresign/cpf-cnpj' ); diff --git a/tests/Integration/Inc/FooterFragmentTest.php b/tests/Integration/Inc/FooterFragmentTest.php index 490bc96..c2b1d41 100644 --- a/tests/Integration/Inc/FooterFragmentTest.php +++ b/tests/Integration/Inc/FooterFragmentTest.php @@ -24,71 +24,12 @@ public function tear_down() { parent::tear_down(); } - /** - * @dataProvider provide_locales - */ - public function test_normalizes_a_locale_into_a_language_tag( $locale, $tag, $storage_key ) { - $this->assertSame( $tag, libresign_theme_site_fragment_normalize_locale_tag( $locale ) ); - $this->assertSame( $storage_key, libresign_theme_site_fragment_storage_key( $locale ) ); - } - - public static function provide_locales() { - yield 'a wordpress locale' => array( 'pt_BR', 'pt-BR', 'pt-BR' ); - yield 'a language alone' => array( 'FR', 'fr', 'fr' ); - yield 'a script subtag' => array( 'zh_hant_tw', 'zh-Hant-TW', 'zh-Hant-TW' ); - yield 'a numeric region' => array( 'es-419', 'es-419', 'es-419' ); - yield 'a long variant stays as is' => array( 'de-DE-1901x', 'de-DE-1901x', 'de-DE-1901x' ); - yield 'repeated separators' => array( 'en--us', 'en-US', 'en-US' ); - yield 'surrounding whitespace' => array( ' nb_NO ', 'nb-NO', 'nb-NO' ); - yield 'only separators' => array( '-_', '', 'default' ); - yield 'empty' => array( '', '', 'default' ); - } - - /** - * @dataProvider provide_fragment_urls - */ - public function test_builds_the_fragment_url( $origin, $fragment_type, $locale, $url ) { - $this->assertSame( $url, libresign_theme_site_fragment_url( $origin, $fragment_type, $locale ) ); - } - - public static function provide_fragment_urls() { - yield 'the default header' => array( 'https://libresign.coop', 'header', '', 'https://libresign.coop/fragments/header' ); - yield 'a localized footer' => array( 'https://libresign.coop/', 'footer', 'pt_BR', 'https://libresign.coop/fragments/pt-BR/footer' ); - yield 'an origin with whitespace' => array( ' https://libresign.coop/ ', 'header', 'fr', 'https://libresign.coop/fragments/fr/header' ); - } - public function test_stores_each_fragment_type_in_its_own_folder() { $this->assertSame( array( 'header', 'footer' ), libresign_theme_site_fragment_supported_types() ); $this->assertStringEndsWith( '/libresign-header', libresign_theme_site_fragment_storage_base_dir( 'header' ) ); $this->assertStringEndsWith( '/libresign-footer', libresign_theme_site_fragment_storage_base_url( 'footer' ) ); } - public function test_reads_the_asset_urls_of_a_fragment() { - $this->assertSame( - array( - 'css' => 'https://libresign.coop/header.css', - 'js' => 'https://libresign.coop/header.js', - ), - libresign_theme_site_fragment_extract_asset_urls( "
" ) - ); - } - - public function test_a_fragment_without_asset_urls_is_an_error() { - $error = libresign_theme_site_fragment_extract_asset_urls( '
' ); - - $this->assertWPError( $error ); - $this->assertSame( 'libresign_theme_site_fragment_missing_assets', $error->get_error_code() ); - } - - public function test_reads_the_locales_the_header_links_to() { - $this->assertSame( - array( 'pt-BR', 'fr', '' ), - libresign_theme_site_fragment_extract_locales_from_header_html( - 'ptfrenfooter' - ) - ); - } - public function test_falls_back_to_the_site_locale() { $this->assertSame( array( 'en-US', '' ), libresign_theme_site_fragment_fallback_locales() ); } @@ -97,31 +38,6 @@ public function test_looks_the_locale_up_from_the_most_specific_to_the_default() $this->assertSame( array( 'en-US', 'en', 'default' ), libresign_theme_site_fragment_locale_lookup_keys() ); } - public function test_drops_the_asset_attributes_after_syncing() { - $this->assertSame( - '
x
', - libresign_theme_site_fragment_strip_runtime_asset_attributes( '
x
' ) - ); - } - - /** - * @dataProvider provide_root_relative_urls - */ - public function test_points_root_relative_urls_to_the_static_site( $content, $rewritten ) { - $this->assertSame( $rewritten, libresign_theme_site_fragment_rewrite_root_relative_urls( $content, 'https://libresign.coop/' ) ); - } - - public static function provide_root_relative_urls() { - yield 'a link' => array( '', '' ); - yield 'an image with single quotes' => array( "", "" ); - yield 'a form and a video poster' => array( '
', '' ); - yield 'a relative path' => array( '', '' ); - } - public function test_only_a_not_found_is_an_optional_error() { $this->assertTrue( libresign_theme_site_fragment_is_optional_http_error( new WP_Error( 'x', 'x', array( 'status' => 404 ) ) ) ); $this->assertFalse( libresign_theme_site_fragment_is_optional_http_error( new WP_Error( 'x', 'x', array( 'status' => 500 ) ) ) ); diff --git a/tests/Integration/Inc/GithubSiteWebhookTest.php b/tests/Integration/Inc/GithubSiteWebhookTest.php index 29d1e33..f2fdfe4 100644 --- a/tests/Integration/Inc/GithubSiteWebhookTest.php +++ b/tests/Integration/Inc/GithubSiteWebhookTest.php @@ -125,85 +125,12 @@ public static function provide_deploy_settings() { yield 'branch name' => array( 'libresign_site_deploy_branch_name', 'LIBRESIGN_SITE_DEPLOY_BRANCH_NAME', 'gh-pages', 'libresign_theme_site_deploy_branch_name' ); } - /** - * @dataProvider provide_signatures - */ - public function test_verifies_the_signature( $body, $signature, $secret, $valid ) { - $this->assertSame( $valid, libresign_theme_verify_github_webhook_signature( $body, $signature, $secret ) ); - } - - public static function provide_signatures() { - $body = '{"zen":"Keep it logically awesome."}'; - $signature = hash_hmac( 'sha256', $body, self::SECRET ); - - yield 'with the sha256 prefix' => array( $body, 'sha256=' . $signature, self::SECRET, true ); - yield 'without the prefix' => array( $body, $signature, self::SECRET, true ); - yield 'in upper case' => array( $body, 'SHA256=' . strtoupper( $signature ), self::SECRET, true ); - yield 'another secret' => array( $body, 'sha256=' . $signature, 'other', false ); - yield 'another body' => array( '{}', 'sha256=' . $signature, self::SECRET, false ); - yield 'not hexadecimal' => array( $body, 'sha256=zz', self::SECRET, false ); - yield 'no signature' => array( $body, '', self::SECRET, false ); - yield 'no secret' => array( $body, 'sha256=' . $signature, ' ', false ); - yield 'no body' => array( '', 'sha256=' . $signature, self::SECRET, false ); - } - - /** - * @dataProvider provide_user_agents - */ - public function test_recognizes_the_github_user_agent( $user_agent, $expected ) { - $this->assertSame( $expected, libresign_theme_is_github_hookshot_user_agent( $user_agent ) ); - } - - public static function provide_user_agents() { - yield 'github' => array( 'GitHub-Hookshot/044aadd', true ); - yield 'github with whitespace' => array( ' GitHub-Hookshot/044aadd ', true ); - yield 'a browser' => array( 'Mozilla/5.0', false ); - yield 'github in the middle' => array( 'curl GitHub-Hookshot/1', false ); - } - - /** - * @dataProvider provide_workflow_names - */ - public function test_reads_the_workflow_name( $payload, $name ) { - $this->assertSame( $name, libresign_theme_site_deploy_workflow_name_from_payload( $payload ) ); - } - - public static function provide_workflow_names() { - yield 'from the run' => array( array( 'workflow_run' => array( 'name' => ' Deploy ' ) ), 'Deploy' ); - yield 'from the workflow' => array( array( 'workflow' => array( 'name' => 'Deploy' ) ), 'Deploy' ); - yield 'the run wins over the workflow' => array( array( 'workflow_run' => array( 'name' => 'Run' ), 'workflow' => array( 'name' => 'Workflow' ) ), 'Run' ); - yield 'none' => array( array(), '' ); - } - - /** - * @dataProvider provide_deploy_runs - */ - public function test_recognizes_the_production_deploy( $changes, $expected ) { - $this->assertSame( $expected, libresign_theme_is_production_site_deploy_workflow_run( $this->production_deploy( $changes ) ) ); - } + public function test_syncs_a_deploy_of_the_configured_branch() { + set_theme_mod( 'libresign_site_deploy_branch_name', 'production' ); - public static function provide_deploy_runs() { - yield 'the production deploy' => array( array(), true ); - yield 'another repository' => array( array( 'repository' => array( 'full_name' => 'LibreSign/other' ) ), false ); - yield 'still running' => array( array( 'action' => 'in_progress' ), false ); - yield 'failed' => array( array( 'workflow_run' => array( 'conclusion' => 'failure' ) ), false ); - yield 'another branch' => array( array( 'workflow_run' => array( 'head_branch' => 'main' ) ), false ); - yield 'another workflow' => array( array( 'workflow_run' => array( 'name' => 'Tests' ) ), false ); - } + $response = $this->deliver( 'workflow_run', $this->production_deploy( array( 'workflow_run' => array( 'head_branch' => 'production' ) ) ) ); - /** - * @dataProvider provide_deploy_starts - */ - public function test_recognizes_the_deploy_starting( $changes, $expected ) { - $this->assertSame( $expected, libresign_theme_is_site_deploy_starting( $this->deploy_starting( $changes ) ) ); - } - - public static function provide_deploy_starts() { - yield 'the deploy starting on main' => array( array(), true ); - yield 'another repository' => array( array( 'repository' => array( 'full_name' => 'LibreSign/other' ) ), false ); - yield 'completed' => array( array( 'action' => 'completed' ), false ); - yield 'another branch' => array( array( 'workflow_run' => array( 'head_branch' => 'gh-pages' ) ), false ); - yield 'another workflow' => array( array( 'workflow_run' => array( 'name' => 'Tests' ) ), false ); + $this->assertSame( 'libresign_theme_site_fragment_http_status', $response->get_data()['code'] ); } public function test_handles_each_delivery_once() { diff --git a/tests/Unit/LogoTest.php b/tests/Unit/LogoTest.php new file mode 100644 index 0000000..17b52d6 --- /dev/null +++ b/tests/Unit/LogoTest.php @@ -0,0 +1,42 @@ +assertSame( $urls, Logo::image_urls( $html ) ); + } + + public static function provide_logo_markup() { + yield 'no markup' => array( '', array() ); + yield 'no image' => array( 'LibreSign', array() ); + yield 'an image without sources' => array( 'LibreSign', array() ); + yield 'only src' => array( '', array( '/logo.png' ) ); + yield 'src and srcset' => array( '', array( '/logo.png', '/logo.png', '/logo-2x.png' ) ); + yield 'width descriptors' => array( "", array( '/a.png', '/b.png' ) ); + yield 'encoded entities' => array( '', array( '/logo.png?a=1&b=2' ) ); + yield 'only the first image counts' => array( '', array( '/a.png' ) ); + } + + public function test_replaces_the_image_with_a_logo_for_each_color_scheme() { + $this->assertSame( + '', + Logo::with_fallback( + '', + 'https://example.org/light.svg', + 'https://example.org/dark.svg' + ) + ); + } + + public function test_leaves_markup_without_an_image_alone() { + $this->assertSame( 'LibreSign', Logo::with_fallback( 'LibreSign', 'light.svg', 'dark.svg' ) ); + } +} diff --git a/tests/Unit/SiteDeployTest.php b/tests/Unit/SiteDeployTest.php new file mode 100644 index 0000000..b9221c2 --- /dev/null +++ b/tests/Unit/SiteDeployTest.php @@ -0,0 +1,143 @@ +assertSame( $valid, SiteDeploy::signature_matches( $body, $signature, $secret ) ); + } + + public static function provide_signatures() { + $body = '{"zen":"Keep it logically awesome."}'; + $signature = hash_hmac( 'sha256', $body, self::SECRET ); + + yield 'with the sha256 prefix' => array( $body, 'sha256=' . $signature, self::SECRET, true ); + yield 'without the prefix' => array( $body, $signature, self::SECRET, true ); + yield 'in upper case' => array( $body, 'SHA256=' . strtoupper( $signature ), self::SECRET, true ); + yield 'another secret' => array( $body, 'sha256=' . $signature, 'other', false ); + yield 'another body' => array( '{}', 'sha256=' . $signature, self::SECRET, false ); + yield 'not hexadecimal' => array( $body, 'sha256=zz', self::SECRET, false ); + yield 'no signature' => array( $body, '', self::SECRET, false ); + yield 'no secret' => array( $body, 'sha256=' . $signature, ' ', false ); + yield 'no body' => array( '', 'sha256=' . $signature, self::SECRET, false ); + } + + /** + * @dataProvider provide_user_agents + */ + public function test_recognizes_the_github_user_agent( $user_agent, $expected ) { + $this->assertSame( $expected, SiteDeploy::is_github_delivery( $user_agent ) ); + } + + public static function provide_user_agents() { + yield 'github' => array( 'GitHub-Hookshot/044aadd', true ); + yield 'github with whitespace' => array( ' GitHub-Hookshot/044aadd ', true ); + yield 'a browser' => array( 'Mozilla/5.0', false ); + yield 'github in the middle' => array( 'curl GitHub-Hookshot/1', false ); + } + + /** + * @dataProvider provide_workflow_names + */ + public function test_reads_the_workflow_name( $payload, $name ) { + $this->assertSame( $name, SiteDeploy::workflow_name( $payload ) ); + } + + public static function provide_workflow_names() { + yield 'from the run' => array( array( 'workflow_run' => array( 'name' => ' Deploy ' ) ), 'Deploy' ); + yield 'from the workflow' => array( array( 'workflow' => array( 'name' => 'Deploy' ) ), 'Deploy' ); + yield 'the run wins over the workflow' => array( array( 'workflow_run' => array( 'name' => 'Run' ), 'workflow' => array( 'name' => 'Workflow' ) ), 'Run' ); + yield 'none' => array( array(), '' ); + yield 'a name that is not text' => array( array( 'workflow_run' => array( 'name' => array( 'Deploy' ) ) ), '' ); + } + + /** + * @dataProvider provide_deploy_runs + */ + public function test_recognizes_the_production_deploy( $changes, $expected ) { + $this->assertSame( $expected, SiteDeploy::is_production_deploy( $this->production_deploy( $changes ), 'LibreSign/site', 'gh-pages', 'pages build and deployment' ) ); + } + + public static function provide_deploy_runs() { + yield 'the production deploy' => array( array(), true ); + yield 'another repository' => array( array( 'repository' => array( 'full_name' => 'LibreSign/other' ) ), false ); + yield 'still running' => array( array( 'action' => 'in_progress' ), false ); + yield 'failed' => array( array( 'workflow_run' => array( 'conclusion' => 'failure' ) ), false ); + yield 'another branch' => array( array( 'workflow_run' => array( 'head_branch' => 'main' ) ), false ); + yield 'another workflow' => array( array( 'workflow_run' => array( 'name' => 'Tests' ) ), false ); + } + + /** + * @dataProvider provide_deploy_starts + */ + public function test_recognizes_the_deploy_starting( $changes, $expected ) { + $this->assertSame( $expected, SiteDeploy::is_deploy_starting( $this->deploy_starting( $changes ), 'LibreSign/site' ) ); + } + + public static function provide_deploy_starts() { + yield 'the deploy starting on main' => array( array(), true ); + yield 'another repository' => array( array( 'repository' => array( 'full_name' => 'LibreSign/other' ) ), false ); + yield 'completed' => array( array( 'action' => 'completed' ), false ); + yield 'another branch' => array( array( 'workflow_run' => array( 'head_branch' => 'gh-pages' ) ), false ); + yield 'another workflow' => array( array( 'workflow_run' => array( 'name' => 'Tests' ) ), false ); + } + + /** + * @dataProvider provide_pull_requests + */ + public function test_finds_the_merged_pull_request( $pull_requests, $number ) { + $this->assertSame( $number, SiteDeploy::merged_pull_request( $pull_requests ) ); + } + + public static function provide_pull_requests() { + yield 'the merged one among others' => array( array( array( 'number' => 41, 'merged_at' => null ), array( 'number' => 42, 'merged_at' => '2026-09-01T00:00:00Z' ) ), 42 ); + yield 'none merged' => array( array( array( 'number' => 41, 'merged_at' => null ) ), 0 ); + yield 'no pull requests' => array( array(), 0 ); + yield 'not a list' => array( null, 0 ); + } + + public function test_lists_the_synced_locales_in_the_comment() { + $this->assertSame( + "✅ **Header and footer fragments updated in production!**\n\n| Fragment | Synced locales |\n|----------|----------------|\n| Header | `default, pt-BR` |\n| Footer | `` |\n\nSource: https://libresign.coop", + SiteDeploy::sync_comment( array( 'header' => array( 'default', 'pt-BR' ) ), 'https://libresign.coop' ) + ); + } + + private function production_deploy( $changes = array() ) { + return array_replace_recursive( + array( + 'action' => 'completed', + 'repository' => array( 'full_name' => 'LibreSign/site' ), + 'workflow_run' => array( + 'name' => 'pages build and deployment', + 'conclusion' => 'success', + 'head_branch' => 'gh-pages', + ), + ), + $changes + ); + } + + private function deploy_starting( $changes = array() ) { + return array_replace_recursive( + array( + 'action' => 'in_progress', + 'repository' => array( 'full_name' => 'LibreSign/site' ), + 'workflow_run' => array( + 'name' => 'Deploy', + 'head_branch' => 'main', + ), + ), + $changes + ); + } +} diff --git a/tests/Unit/SiteFragmentTest.php b/tests/Unit/SiteFragmentTest.php new file mode 100644 index 0000000..9491f35 --- /dev/null +++ b/tests/Unit/SiteFragmentTest.php @@ -0,0 +1,116 @@ +assertSame( $tag, SiteFragment::language_tag( $locale ) ); + $this->assertSame( $storage_key, SiteFragment::storage_key( $locale ) ); + } + + public static function provide_locales() { + yield 'a wordpress locale' => array( 'pt_BR', 'pt-BR', 'pt-BR' ); + yield 'a language alone' => array( 'FR', 'fr', 'fr' ); + yield 'a script subtag' => array( 'zh_hant_tw', 'zh-Hant-TW', 'zh-Hant-TW' ); + yield 'a numeric region' => array( 'es-419', 'es-419', 'es-419' ); + yield 'a long variant stays as is' => array( 'de-DE-1901x', 'de-DE-1901x', 'de-DE-1901x' ); + yield 'repeated separators' => array( 'en--us', 'en-US', 'en-US' ); + yield 'surrounding whitespace' => array( ' nb_NO ', 'nb-NO', 'nb-NO' ); + yield 'only separators' => array( '-_', '', 'default' ); + yield 'empty' => array( '', '', 'default' ); + } + + /** + * @dataProvider provide_fragment_urls + */ + public function test_builds_the_fragment_url( $origin, $fragment_type, $locale, $url ) { + $this->assertSame( $url, SiteFragment::url( $origin, $fragment_type, $locale ) ); + } + + public static function provide_fragment_urls() { + yield 'the default header' => array( 'https://libresign.coop', 'header', '', 'https://libresign.coop/fragments/header' ); + yield 'a localized footer' => array( 'https://libresign.coop/', 'footer', 'pt_BR', 'https://libresign.coop/fragments/pt-BR/footer' ); + yield 'an origin with whitespace' => array( ' https://libresign.coop/ ', 'header', 'fr', 'https://libresign.coop/fragments/fr/header' ); + } + + public function test_reads_the_asset_urls_of_a_fragment() { + $this->assertSame( + array( + 'css' => 'https://libresign.coop/header.css', + 'js' => 'https://libresign.coop/header.js', + ), + SiteFragment::asset_urls( "
" ) + ); + } + + public function test_a_fragment_without_both_asset_urls_has_none() { + $this->assertNull( SiteFragment::asset_urls( '
' ) ); + } + + public function test_reads_the_locales_the_header_links_to() { + $this->assertSame( + array( 'pt-BR', 'fr', '' ), + SiteFragment::linked_locales( + 'ptfrenfooter' + ) + ); + } + + public function test_drops_the_asset_attributes_after_syncing() { + $this->assertSame( + '
x
', + SiteFragment::without_asset_attributes( '
x
' ) + ); + } + + /** + * @dataProvider provide_root_relative_urls + */ + public function test_points_root_relative_urls_to_the_static_site( $content, $rewritten ) { + $this->assertSame( $rewritten, SiteFragment::with_absolute_urls( $content, 'https://libresign.coop/' ) ); + } + + public static function provide_root_relative_urls() { + yield 'a link' => array( '', '' ); + yield 'an image with single quotes' => array( "", "" ); + yield 'a form and a video poster' => array( '', '' ); + yield 'a relative path' => array( '', '' ); + } + + /** + * @dataProvider provide_origins + */ + public function test_normalizes_an_origin( $origin, $normalized ) { + $this->assertSame( $normalized, SiteFragment::origin( $origin ) ); + } + + public static function provide_origins() { + yield 'already normalized' => array( 'https://libresign.coop', 'https://libresign.coop' ); + yield 'the trailing slashes' => array( ' https://libresign.coop// ', 'https://libresign.coop' ); + yield 'empty' => array( '', '' ); + } + + /** + * @dataProvider provide_locale_candidates + */ + public function test_looks_the_locale_up_from_the_most_specific_to_the_default( $candidates, $keys ) { + $this->assertSame( $keys, SiteFragment::lookup_keys( $candidates ) ); + } + + public static function provide_locale_candidates() { + yield 'a polylang slug and locale' => array( array( 'pt', 'pt_BR', 'en_US', 'en_US' ), array( 'pt', 'pt-BR', 'en-US', 'en', 'default' ) ); + yield 'a script subtag' => array( array( 'zh_Hant_TW' ), array( 'zh-Hant-TW', 'zh', 'default' ) ); + yield 'nothing usable' => array( array( '', '-' ), array( 'default' ) ); + } +} diff --git a/tests/Unit/TaxIdTest.php b/tests/Unit/TaxIdTest.php new file mode 100644 index 0000000..19ff5be --- /dev/null +++ b/tests/Unit/TaxIdTest.php @@ -0,0 +1,69 @@ +assertSame( $valid, TaxId::valid_cpf( $cpf ) ); + } + + public static function provide_cpfs() { + yield 'digits only' => array( '52998224725', true ); + yield 'formatted' => array( '529.982.247-25', true ); + yield 'another valid one' => array( '123.456.789-09', true ); + yield 'wrong first check digit' => array( '529.982.247-35', false ); + yield 'wrong second check digit' => array( '529.982.247-24', false ); + yield 'every digit the same' => array( '111.111.111-11', false ); + yield 'too short' => array( '5299822472', false ); + yield 'too long' => array( '529982247250', false ); + yield 'empty' => array( '', false ); + } + + /** + * @dataProvider provide_cnpjs + */ + public function test_validates_a_cnpj( $cnpj, $valid ) { + $this->assertSame( $valid, TaxId::valid_cnpj( $cnpj ) ); + } + + public static function provide_cnpjs() { + yield 'digits only' => array( '11222333000181', true ); + yield 'formatted' => array( '11.222.333/0001-81', true ); + yield 'alphanumeric' => array( '12.ABC.345/01DE-35', true ); + yield 'alphanumeric in lowercase' => array( '12.abc.345/01de-35', true ); + yield 'wrong second check digit' => array( '11.222.333/0001-80', false ); + yield 'wrong alphanumeric check digit' => array( '12.ABC.345/01DE-34', false ); + yield 'letters in the check digits' => array( '12.ABC.345/01DE-3A', false ); + yield 'every digit the same' => array( '00.000.000/0000-00', false ); + yield 'symbols other than the formatting' => array( '12.ABC.345/01D*-35', false ); + yield 'too short' => array( '1122233300018', false ); + yield 'empty' => array( '', false ); + } + + /** + * @dataProvider provide_values + */ + public function test_names_the_problem_of_a_value( $value, $error_code ) { + $this->assertSame( $error_code, TaxId::error_code( $value ) ); + } + + public static function provide_values() { + yield 'empty' => array( '', 'libresign_cpf_cnpj_required' ); + yield 'blank' => array( ' ', 'libresign_cpf_cnpj_required' ); + yield 'a valid cpf' => array( '529.982.247-25', '' ); + yield 'a valid cpf with surrounding spaces' => array( ' 529.982.247-25 ', '' ); + yield 'an invalid cpf' => array( '529.982.247-24', 'invalid_cpf' ); + yield 'a valid cnpj' => array( '11.222.333/0001-81', '' ); + yield 'a valid alphanumeric cnpj' => array( '12.abc.345/01de-35', '' ); + yield 'an invalid cnpj' => array( '11.222.333/0001-80', 'invalid_cnpj' ); + yield 'fourteen characters decide for cnpj' => array( '529 982 247 25', 'invalid_cnpj' ); + yield 'neither length' => array( '123456789', 'invalid_cpf_cnpj' ); + } +}