From 89c6fbbb7c2b3bef9b849f465375360f448da6fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=A9stor=20Soriano?= Date: Mon, 19 Feb 2024 12:03:46 +0100 Subject: [PATCH] Add the Receipts Rendering Engine (#43502) --- plugins/woocommerce/changelog/pr-43502 | 4 + .../woocommerce/includes/class-wc-install.php | 10 + .../woocommerce/includes/rest-api/Server.php | 6 +- .../includes/wc-update-functions.php | 18 + .../EnginesServiceProvider.php | 7 +- .../ReceiptRendering/CardIcons/amex.svg | 14 + .../ReceiptRendering/CardIcons/diners.svg | 15 + .../ReceiptRendering/CardIcons/discover.svg | 14 + .../ReceiptRendering/CardIcons/interac.svg | 1 + .../ReceiptRendering/CardIcons/jcb.svg | 29 ++ .../ReceiptRendering/CardIcons/mastercard.svg | 31 ++ .../ReceiptRendering/CardIcons/unknown.svg | 1 + .../ReceiptRendering/CardIcons/visa.svg | 17 + .../ReceiptRenderingEngine.php | 350 ++++++++++++++++++ .../ReceiptRenderingRestController.php | 203 ++++++++++ .../Templates/order-receipt.php | 106 ++++++ .../src/Internal/RestApiControllerBase.php | 233 ++++++++++++ .../TransientFiles/TransientFilesEngine.php | 47 ++- .../ReceiptRenderingEngineTest.php | 255 +++++++++++++ .../TransientFilesEngineTest.php | 59 +++ 20 files changed, 1410 insertions(+), 10 deletions(-) create mode 100644 plugins/woocommerce/changelog/pr-43502 create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/amex.svg create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/diners.svg create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/discover.svg create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/interac.svg create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/jcb.svg create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/mastercard.svg create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/unknown.svg create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/visa.svg create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingRestController.php create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/Templates/order-receipt.php create mode 100644 plugins/woocommerce/src/Internal/RestApiControllerBase.php create mode 100644 plugins/woocommerce/tests/php/src/Internal/ReceiptRendering/ReceiptRenderingEngineTest.php diff --git a/plugins/woocommerce/changelog/pr-43502 b/plugins/woocommerce/changelog/pr-43502 new file mode 100644 index 00000000000..7f0a562057b --- /dev/null +++ b/plugins/woocommerce/changelog/pr-43502 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add the receipts rendering engine \ No newline at end of file diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php index 8ac42cdea6f..d2f40c04d2c 100644 --- a/plugins/woocommerce/includes/class-wc-install.php +++ b/plugins/woocommerce/includes/class-wc-install.php @@ -8,6 +8,7 @@ use Automattic\Jetpack\Constants; use Automattic\WooCommerce\Admin\Notes\Notes; +use Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine; use Automattic\WooCommerce\Internal\DataStores\Orders\{ CustomOrdersTableController, DataSynchronizer, OrdersTableDataStore }; use Automattic\WooCommerce\Internal\Features\FeaturesController; use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator; @@ -243,6 +244,9 @@ class WC_Install { '8.6.0' => array( 'wc_update_860_remove_recommended_marketing_plugins_transient', ), + '8.7.0' => array( + 'wc_update_870_prevent_listing_of_transient_files_directory', + ), ); /** @@ -456,6 +460,11 @@ class WC_Install { // plugin version update. We base plugin age off of this value. add_option( 'woocommerce_admin_install_timestamp', time() ); + // Force a flush of rewrite rules even if the corresponding hook isn't initialized yet. + if ( ! has_action( 'woocommerce_flush_rewrite_rules' ) ) { + flush_rewrite_rules(); + } + /** * Flush the rewrite rules after install or update. * @@ -557,6 +566,7 @@ class WC_Install { WC()->query->add_endpoints(); WC_API::add_endpoint(); WC_Auth::add_endpoint(); + TransientFilesEngine::add_endpoint(); } /** diff --git a/plugins/woocommerce/includes/rest-api/Server.php b/plugins/woocommerce/includes/rest-api/Server.php index e1ba98a00e0..cfe256ba012 100644 --- a/plugins/woocommerce/includes/rest-api/Server.php +++ b/plugins/woocommerce/includes/rest-api/Server.php @@ -37,9 +37,13 @@ class Server { * Register REST API routes. */ public function register_rest_routes() { + $container = wc_get_container(); foreach ( $this->get_rest_namespaces() as $namespace => $controllers ) { foreach ( $controllers as $controller_name => $controller_class ) { - $this->controllers[ $namespace ][ $controller_name ] = new $controller_class(); + $this->controllers[ $namespace ][ $controller_name ] = + 0 === strpos( $controller_class, 'WC_REST_' ) ? + new $controller_class() : + $container->get( $controller_class ); $this->controllers[ $namespace ][ $controller_name ]->register_routes(); } } diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php index 110bcf26e33..c6776671b30 100644 --- a/plugins/woocommerce/includes/wc-update-functions.php +++ b/plugins/woocommerce/includes/wc-update-functions.php @@ -2649,3 +2649,21 @@ LIMIT 250 function wc_update_860_remove_recommended_marketing_plugins_transient() { delete_transient( 'wc_marketing_recommended_plugins' ); } + +/** + * Create an .htaccess file and an empty index.html file to prevent listing of the default transient files directory, + * if the directory exists. + */ +function wc_update_870_prevent_listing_of_transient_files_directory() { + global $wp_filesystem; + + $default_transient_files_dir = untrailingslashit( wp_upload_dir()['basedir'] ) . '/woocommerce_transient_files'; + if ( ! is_dir( $default_transient_files_dir ) ) { + return; + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + \WP_Filesystem(); + $wp_filesystem->put_contents( $default_transient_files_dir . '/.htaccess', 'deny from all' ); + $wp_filesystem->put_contents( $default_transient_files_dir . '/index.html', '' ); +} diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/EnginesServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/EnginesServiceProvider.php index 22859291cec..5b91c652648 100644 --- a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/EnginesServiceProvider.php +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/EnginesServiceProvider.php @@ -5,7 +5,8 @@ namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders; -use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider; +use Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingEngine; +use Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController; use Automattic\WooCommerce\Proxies\LegacyProxy; use Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine; @@ -21,6 +22,8 @@ class EnginesServiceProvider extends AbstractInterfaceServiceProvider { */ protected $provides = array( TransientFilesEngine::class, + ReceiptRenderingEngine::class, + ReceiptRenderingRestController::class, ); /** @@ -28,5 +31,7 @@ class EnginesServiceProvider extends AbstractInterfaceServiceProvider { */ public function register() { $this->share_with_implements_tags( TransientFilesEngine::class )->addArgument( LegacyProxy::class ); + $this->share( ReceiptRenderingEngine::class )->addArguments( array( TransientFilesEngine::class, LegacyProxy::class ) ); + $this->share_with_implements_tags( ReceiptRenderingRestController::class ); } } diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/amex.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/amex.svg new file mode 100644 index 00000000000..6efb571092f --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/amex.svg @@ -0,0 +1,14 @@ + + + + Slice 1 + Created with Sketch. + + + + + + + + + \ No newline at end of file diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/diners.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/diners.svg new file mode 100644 index 00000000000..ddc73dfcd75 --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/diners.svg @@ -0,0 +1,15 @@ + + + + diners + Created with Sketch. + + + + + + + + + + \ No newline at end of file diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/discover.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/discover.svg new file mode 100644 index 00000000000..9fab15cb822 --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/discover.svg @@ -0,0 +1,14 @@ + + + + discover + Created with Sketch. + + + + + + + + + \ No newline at end of file diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/interac.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/interac.svg new file mode 100644 index 00000000000..de54495079e --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/interac.svg @@ -0,0 +1 @@ +Asset 1 \ No newline at end of file diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/jcb.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/jcb.svg new file mode 100644 index 00000000000..674fd14272f --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/jcb.svg @@ -0,0 +1,29 @@ + + + + Slice 1 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/mastercard.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/mastercard.svg new file mode 100644 index 00000000000..680a4669a72 --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/mastercard.svg @@ -0,0 +1,31 @@ + + + + Slice 1 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/unknown.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/unknown.svg new file mode 100644 index 00000000000..dbb99b2f039 --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/unknown.svg @@ -0,0 +1 @@ +credit-card \ No newline at end of file diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/visa.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/visa.svg new file mode 100644 index 00000000000..b40af5cfa9f --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/visa.svg @@ -0,0 +1,17 @@ + + + + Slice 1 + Created with Sketch. + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php b/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php new file mode 100644 index 00000000000..1c6ec83cf77 --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php @@ -0,0 +1,350 @@ +transient_files_engine = $transient_files_engine; + $this->legacy_proxy = $legacy_proxy; + } + + /** + * Get the (transient) file name of the receipt for an order, creating a new file if necessary. + * + * If $force_new is false, and a receipt file for the order already exists (as pointed by order meta key + * RECEIPT_FILE_NAME_META_KEY), then the name of the already existing receipt file is returned. + * + * If $force_new is true, OR if it's false but no receipt file for the order exists (no order meta with key + * RECEIPT_FILE_NAME_META_KEY exists, OR it exists but the file it points to doesn't), then a new receipt + * transient file is created with the supplied expiration date (defaulting to "tomorrow"), and the new file name + * is stored as order meta with the key RECEIPT_FILE_NAME_META_KEY. + * + * @param int|WC_Order $order The order object or order id to get the receipt for. + * @param string|int|null $expiration_date GMT expiration date formatted as yyyy-mm-dd, or as a timestamp, or null for "tomorrow". + * @param bool $force_new If true, creates a new receipt file even if one already exists for the order. + * @return string|null The file name of the new or already existing receipt file, null if an order id is passed and the order doesn't exist. + * @throws \InvalidArgumentException Invalid expiration date (wrongly formatted, or it's a date in the past). + * @throws Exception The directory to store the file doesn't exist and can't be created. + */ + public function generate_receipt( $order, $expiration_date = null, bool $force_new = false ) : ?string { + if ( ! $order instanceof WC_Order ) { + $order = wc_get_order( $order ); + if ( false === $order ) { + return null; + } + } + + if ( ! $force_new ) { + $existing_receipt_filename = $this->get_existing_receipt( $order ); + if ( ! is_null( $existing_receipt_filename ) ) { + return $existing_receipt_filename; + } + } + + $expiration_date ??= + $this->legacy_proxy->call_function( + 'gmdate', + 'Y-m-d', + $this->legacy_proxy->call_function( + 'strtotime', + '+1 days' + ) + ); + + // phpcs:ignore WordPress.PHP.DontExtract.extract_extract + extract( $this->get_order_data( $order ) ); + + ob_start(); + include __dir__ . '/Templates/order-receipt.php'; + $rendered_template = ob_get_contents(); + ob_end_clean(); + + $file_name = $this->transient_files_engine->create_transient_file( $rendered_template, $expiration_date ); + + $order->update_meta_data( self::RECEIPT_FILE_NAME_META_KEY, $file_name ); + $order->save_meta_data(); + + return $file_name; + } + + /** + * Get the file name of an existing receipt file for an order. + * + * A receipt is considered to be available for the order if there's an order meta entry with key + * RECEIPT_FILE_NAME_META_KEY AND the transient file it points to exists AND it has not expired. + * + * @param WC_Order $order The order object or order id to get the receipt for. + * @return string|null The receipt file name, or null if no receipt is currently available for the order. + * @throws Exception Thrown if a wrong file path is passed. + */ + public function get_existing_receipt( $order ): ?string { + if ( ! $order instanceof WC_Order ) { + $order = wc_get_order( $order ); + if ( false === $order ) { + return null; + } + } + + $existing_receipt_filename = $order->get_meta( self::RECEIPT_FILE_NAME_META_KEY, true ); + + if ( '' === $existing_receipt_filename ) { + return null; + } + + $file_path = $this->transient_files_engine->get_transient_file_path( $existing_receipt_filename ); + if ( is_null( $file_path ) ) { + return null; + } + + return $this->transient_files_engine->file_has_expired( $file_path ) ? null : $existing_receipt_filename; + } + + /** + * Get the order data that the receipt template will use. + * + * @param WC_Order $order The order to get the data from. + * @return array The order data as an associative array. + */ + private function get_order_data( WC_Order $order ): array { + $store_name = get_bloginfo( 'name' ); + if ( $store_name ) { + /* translators: %s = store name */ + $receipt_title = sprintf( __( 'Receipt from %s', 'woocommerce' ), $store_name ); + } else { + $receipt_title = __( 'Receipt', 'woocommerce' ); + } + + $order_id = $order->get_id(); + if ( $order_id ) { + /* translators: %d = order id */ + $summary_title = sprintf( __( 'Summary: Order #%d', 'woocommerce' ), $order->get_id() ); + } else { + $summary_title = __( 'Summary', 'woocommerce' ); + } + + $get_price_args = array( 'currency' => $order->get_currency() ); + + $line_items_info = array(); + $line_items = $order->get_items( 'line_item' ); + foreach ( $line_items as $line_item ) { + $line_item_product = $line_item->get_product(); + $line_item_title = + ( $line_item_product instanceof \WC_Product_Variation ) ? + ( wc_get_product( $line_item_product->get_parent_id() )->get_name() ) . '. ' . $line_item_product->get_attribute_summary() : + $line_item_product->get_name(); + $line_items_info[] = array( + 'title' => wp_kses( $line_item_title, array() ), + 'quantity' => $line_item->get_quantity(), + 'amount' => wc_price( $line_item->get_subtotal(), $get_price_args ), + ); + } + + $line_items_info[] = array( + 'title' => __( 'Subtotal', 'woocommerce' ), + 'amount' => wc_price( $order->get_subtotal(), $get_price_args ), + ); + + $coupon_names = ArrayUtil::select( $order->get_coupons(), 'get_name', ArrayUtil::SELECT_BY_OBJECT_METHOD ); + if ( ! empty( $coupon_names ) ) { + $line_items_info[] = array( + /* translators: %s = comma-separated list of coupon codes */ + 'title' => sprintf( __( 'Discount (%s)', 'woocommerce' ), join( ', ', $coupon_names ) ), + 'amount' => wc_price( -$order->get_total_discount(), $get_price_args ), + ); + } + + foreach ( $order->get_fees() as $fee ) { + $name = $fee->get_name(); + $line_items_info[] = array( + 'title' => '' === $name ? __( 'Fee', 'woocommerce' ) : $name, + 'amount' => wc_price( $fee->get_total(), $get_price_args ), + ); + } + + $shipping_total = (float) $order->get_shipping_total(); + if ( $shipping_total ) { + $line_items_info[] = array( + 'title' => __( 'Shipping', 'woocommerce' ), + 'amount' => wc_price( $order->get_shipping_total(), $get_price_args ), + ); + } + + $total_taxes = 0; + foreach ( $order->get_taxes() as $tax ) { + $total_taxes += (float) $tax->get_tax_total() + (float) $tax->get_shipping_tax_total(); + } + + if ( $total_taxes ) { + $line_items_info[] = array( + 'title' => __( 'Taxes', 'woocommerce' ), + 'amount' => wc_price( $total_taxes, $get_price_args ), + ); + } + + $line_items_info[] = array( + 'title' => __( 'Amount Paid', 'woocommerce' ), + 'amount' => wc_price( $order->get_total(), $get_price_args ), + ); + + return array( + 'constants' => array( + 'font_size' => self::FONT_SIZE, + 'margin' => self::MARGIN, + 'title_font_size' => self::TITLE_FONT_SIZE, + 'footer_font_size' => self::FOOTER_FONT_SIZE, + 'line_height' => self::LINE_HEIGHT, + 'icon_height' => self::ICON_HEIGHT, + 'icon_width' => self::ICON_WIDTH, + ), + 'texts' => array( + 'receipt_title' => $receipt_title, + 'amount_paid_section_title' => __( 'Amount Paid', 'woocommerce' ), + 'date_paid_section_title' => __( 'Date Paid', 'woocommerce' ), + 'payment_method_section_title' => __( 'Payment method', 'woocommerce' ), + 'summary_section_title' => $summary_title, + 'order_notes_section_title' => __( 'Notes', 'woocommerce' ), + 'app_name' => __( 'Application Name', 'woocommerce' ), + 'aid' => __( 'AID', 'woocommerce' ), + 'account_type' => __( 'Account Type', 'woocommerce' ), + ), + 'formatted_amount' => wc_price( $order->get_total(), $get_price_args ), + 'formatted_date' => wc_format_datetime( $order->get_date_paid() ), + 'line_items' => $line_items_info, + 'payment_method' => $order->get_payment_method_title(), + 'notes' => array_map( 'get_comment_text', $order->get_customer_order_notes() ), + 'payment_info' => $this->get_woo_pay_data( $order ), + ); + } + + /** + * Get the order data related to WooCommerce Payments. + * + * It will return null if any of these is true: + * + * - Payment method is not 'woocommerce_payments". + * - WooCommerce Payments is not installed. + * - No intent id is stored for the order. + * - Retrieving the payment information from Stripe API (providing the intent id) fails. + * - The received data set doesn't contain the expected information. + * + * @param WC_Order $order The order to get the data from. + * @return array|null An array of payment information for the order, or null if not available. + */ + private function get_woo_pay_data( WC_Order $order ): ?array { + // For testing purposes: if WooCommerce Payments development mode is enabled, + // an order meta item with key '_wcpay_payment_details' will be used if it exists as a replacement + // for the call to the Stripe API's 'get intent' endpoint. + // The value must be the JSON encoding of an array simulating the "payment_details" part of the response from the endpoint + // (at the very least it must contain the "card_present" key). + $payment_details = json_decode( defined( 'WCPAY_DEV_MODE' ) && WCPAY_DEV_MODE ? $order->get_meta( '_wcpay_payment_details' ) : false, true ); + + if ( ! $payment_details ) { + if ( 'woocommerce_payments' !== $order->get_payment_method() ) { + return null; + } + + if ( ! class_exists( \WC_Payments::class ) ) { + return null; + } + + $intent_id = $order->get_meta( '_intent_id' ); + if ( ! $intent_id ) { + return null; + } + + try { + $payment_details = \WC_Payments::get_payments_api_client()->get_intent( $intent_id )->get_charge()->get_payment_method_details(); + } catch ( Exception $ex ) { + $order_id = $order->get_id(); + $message = $ex->getMessage(); + wc_get_logger()->error( StringUtil::class_name_without_namespace( static::class ) . " - retrieving info for charge {$intent_id} for order {$order_id}: {$message}" ); + return null; + } + } + + $card_data = $payment_details['card_present'] ?? null; + if ( is_null( $card_data ) ) { + return null; + } + + $card_brand = $card_data['brand'] ?? ''; + if ( ! in_array( $card_brand, self::KNOWN_CARD_TYPES, true ) ) { + $card_brand = 'unknown'; + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $card_svg = base64_encode( file_get_contents( __DIR__ . "/CardIcons/{$card_brand}.svg" ) ); + + return array( + 'card_icon' => $card_svg, + 'card_last4' => wp_kses( $card_data['last4'] ?? '', array() ), + 'app_name' => wp_kses( $card_data['receipt']['application_preferred_name'] ?? null, array() ), + 'aid' => wp_kses( $card_data['receipt']['dedicated_file_name'] ?? null, array() ), + 'account_type' => wp_kses( $card_data['receipt']['account_type'] ?? null, array() ), + ); + } +} diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingRestController.php b/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingRestController.php new file mode 100644 index 00000000000..3674da31e7b --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingRestController.php @@ -0,0 +1,203 @@ +route_namespace, + '/orders/(?P[\d]+)/receipt', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => fn( $request ) => $this->run( $request, 'create_order_receipt' ), + 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_shop_order', $request->get_param( 'id' ) ), + 'args' => $this->get_args_for_create_order_receipt(), + 'schema' => $this->get_schema_for_get_and_post_order_receipt(), + ), + ) + ); + + register_rest_route( + $this->route_namespace, + '/orders/(?P[\d]+)/receipt', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => fn( $request ) => $this->run( $request, 'get_order_receipt' ), + 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_shop_order', $request->get_param( 'id' ) ), + 'args' => $this->get_args_for_get_order_receipt(), + 'schema' => $this->get_schema_for_get_and_post_order_receipt(), + ), + ) + ); + + } + + /** + * Handle the GET /orders/id/receipt: + * + * Return the data for a receipt if it exists, or a 404 error if it doesn't. + * + * @param WP_REST_Request $request The received request. + * @return array|WP_Error + */ + public function get_order_receipt( WP_REST_Request $request ) { + $order_id = $request->get_param( 'id' ); + $filename = wc_get_container()->get( ReceiptRenderingEngine::class )->get_existing_receipt( $order_id ); + + return is_null( $filename ) ? + new WP_Error( 'woocommerce_rest_not_found', __( 'Receipt not found', 'woocommerce' ), array( 'status' => 404 ) ) : + $this->get_response_for_file( $filename ); + } + + /** + * Handle the POST /orders/id/receipt: + * + * Return the data for a receipt if it exists, or create a new receipt and return its data otherwise. + * + * Optional query string arguments: + * + * expiration_date: formatted as yyyy-mm-dd. + * expiration_days: a number, 0 is today, 1 is tomorrow, etc. + * force_new: defaults to false, if true, create a new receipt even if one already exists for the order. + * + * If neither expiration_date nor expiration_days are supplied, the default is expiration_days = 1. + * + * @param WP_REST_Request $request The received request. + * @return array|WP_Error Request response or an error. + */ + public function create_order_receipt( WP_REST_Request $request ) { + $expiration_date = + $request->get_param( 'expiration_date' ) ?? + gmdate( 'Y-m-d', strtotime( "+{$request->get_param('expiration_days')} days" ) ); + + $order_id = $request->get_param( 'id' ); + + $filename = wc_get_container()->get( ReceiptRenderingEngine::class )->generate_receipt( $order_id, $expiration_date, $request->get_param( 'force_new' ) ); + + return is_null( $filename ) ? + new WP_Error( 'woocommerce_rest_not_found', __( 'Order not found', 'woocommerce' ), array( 'status' => 404 ) ) : + $this->get_response_for_file( $filename ); + } + + /** + * Formats the response for both the GET and POST endpoints. + * + * @param string $filename The filename to return the information for. + * @return array The data for the actual response to be returned. + */ + private function get_response_for_file( string $filename ): array { + $expiration_date = TransientFilesEngine::get_expiration_date( $filename ); + $public_url = wc_get_container()->get( TransientFilesEngine::class )->get_public_url( $filename ); + + return array( + 'receipt_url' => $public_url, + 'expiration_date' => $expiration_date, + ); + } + + /** + * Get the accepted arguments for the GET request. + * + * @return array[] The accepted arguments for the GET request. + */ + private function get_args_for_get_order_receipt(): array { + return array( + 'id' => array( + 'description' => __( 'Unique identifier of the order.', 'woocommerce' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ); + } + + /** + * Get the schema for both the GET and the POST requests. + * + * @return array[] + */ + private function get_schema_for_get_and_post_order_receipt(): array { + $schema = $this->get_base_schema(); + $schema['properties'] = array( + 'receipt_url' => array( + 'description' => __( 'Public url of the receipt.', 'woocommerce' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'expiration_date' => array( + 'description' => __( 'Expiration date of the receipt, formatted as yyyy-mm-dd.', 'woocommerce' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ); + + return $schema; + } + + /** + * Get the accepted arguments for the POST request. + * + * @return array[] + */ + private function get_args_for_create_order_receipt(): array { + return array( + 'id' => array( + 'description' => __( 'Unique identifier of the order.', 'woocommerce' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + 'expiration_date' => array( + 'description' => __( 'Expiration date formatted as yyyy-mm-dd.', 'woocommerce' ), + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'default' => null, + ), + 'expiration_days' => array( + 'description' => __( 'Number of days to be added to the current date to get the expiration date.', 'woocommerce' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'default' => 1, + ), + 'force_new' => array( + 'description' => __( 'True to force the creation of a new receipt even if one already exists and has not expired yet.', 'woocommerce' ), + 'type' => 'boolean', + 'required' => false, + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'default' => false, + ), + ); + } +} diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/Templates/order-receipt.php b/plugins/woocommerce/src/Internal/ReceiptRendering/Templates/order-receipt.php new file mode 100644 index 00000000000..176013bad5a --- /dev/null +++ b/plugins/woocommerce/src/Internal/ReceiptRendering/Templates/order-receipt.php @@ -0,0 +1,106 @@ + + + + + + + + +
+

+

+

+ +

+

+

+ +

+ +

+

+ + - + +

+ +

+ +
+ +

+ + + + + + +
×
+ + +

+ +

+ +
+

+ '; + } + if ( $payment_info['aid'] ) { + echo $texts['aid'] . ': ' . $payment_info['aid'] . '
'; + } + if ( $payment_info['account_type'] ) { + echo $texts['account_type'] . ': ' . $payment_info['account_type']; + } + ?> +

+
+ + + + diff --git a/plugins/woocommerce/src/Internal/RestApiControllerBase.php b/plugins/woocommerce/src/Internal/RestApiControllerBase.php new file mode 100644 index 00000000000..5a08f8ec4d8 --- /dev/null +++ b/plugins/woocommerce/src/Internal/RestApiControllerBase.php @@ -0,0 +1,233 @@ +route_namespace, + * '/foobars/(?P[\d]+)', + * array( + * array( + * 'methods' => \WP_REST_Server::READABLE, + * 'callback' => fn( $request ) => $this->run( $request, 'get_foobar' ), + * 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_foobars', $request->get_param( 'id' ) ), + * 'args' => $this->get_args_for_get_foobar(), + * 'schema' => $this->get_schema_for_get_foobar(), + * ), + * ) + * ); + * } + * + * protected function get_foobar( \WP_REST_Request $request ) { + * return array( 'message' => 'Get foobar with id ' . $request->get_param(' id' ) ); + * } + * + * private function get_args_for_get_foobar(): array { + * return array( + * 'id' => array( + * 'description' => __( 'Unique identifier of the foobar.', 'woocommerce' ), + * 'type' => 'integer', + * 'context' => array( 'view', 'edit' ), + * 'readonly' => true, + * ), + * ); + * } + * + * private function get_schema_for_get_foobar(): array { + * $schema = $this->get_base_schema(); + * $schema['properties'] = array( + * 'message' => array( + * 'description' => __( 'A message.', 'woocommerce' ), + * 'type' => 'string', + * 'context' => array( 'view', 'edit' ), + * 'readonly' => true, + * ), + * ); + * return $schema; + * } + * + * } + */ +abstract class RestApiControllerBase implements RegisterHooksInterface { + use AccessiblePrivateMethods; + + /** + * The root namespace for the JSON REST API endpoints. + * + * @var string + */ + protected string $route_namespace = 'wc/v3'; + + /** + * Holds authentication error messages for each HTTP verb. + * + * @var array + */ + protected array $authentication_errors_by_method; + + /** + * Class constructor. + */ + public function __construct() { + $this->authentication_errors_by_method = array( + 'GET' => array( + 'code' => 'woocommerce_rest_cannot_view', + 'message' => __( 'Sorry, you cannot view resources.', 'woocommerce' ), + ), + 'POST' => array( + 'code' => 'woocommerce_rest_cannot_create', + 'message' => __( 'Sorry, you cannot create resources.', 'woocommerce' ), + ), + 'DELETE' => array( + 'code' => 'woocommerce_rest_cannot_delete', + 'message' => __( 'Sorry, you cannot delete resources.', 'woocommerce' ), + ), + ); + } + + /** + * Register the hooks used by the class. + */ + public function register() { + static::add_filter( 'woocommerce_rest_api_get_rest_namespaces', array( $this, 'handle_woocommerce_rest_api_get_rest_namespaces' ) ); + } + + /** + * Handle the woocommerce_rest_api_get_rest_namespaces filter + * to add ourselves to the list of REST API controllers registered by WooCommerce. + * + * @param array $namespaces The original list of WooCommerce REST API namespaces/controllers. + * @return array The updated list of WooCommerce REST API namespaces/controllers. + */ + protected function handle_woocommerce_rest_api_get_rest_namespaces( array $namespaces ): array { + $namespaces['wc/v3'][ $this->get_rest_api_namespace() ] = static::class; + return $namespaces; + } + + /** + * Get the WooCommerce REST API namespace for the class. It must be unique across all other derived classes + * and the keys returned by the 'get_vX_controllers' methods in includes/rest-api/Server.php. + * Note that this value is NOT related to the route namespace. + * + * @return string + */ + abstract protected function get_rest_api_namespace(): string; + + /** + * Register the REST API endpoints handled by this controller. + * + * Use 'register_rest_route' in the usual way, it's recommended to use the 'run' method for 'callback' + * and the 'check_permission' method for 'permission_check', see the example in the class comment. + */ + abstract public function register_routes(); + + /** + * Handle a request for one of the provided REST API endpoints. + * + * If an exception is thrown, the exception message will be returned as part of the response + * if the user has the 'manage_woocommerce' capability. + * + * Note that the method specified in $method_name must have a 'protected' visibility and accept one argument of type 'WP_REST_Request'. + * + * @param WP_REST_Request $request The incoming HTTP REST request. + * @param string $method_name The name of the class method to execute. It must be protected and accept one argument of type 'WP_REST_Request'. + * @return WP_Error|WP_HTTP_Response|WP_REST_Response The response to send back to the client. + */ + protected function run( WP_REST_Request $request, string $method_name ) { + try { + return rest_ensure_response( $this->$method_name( $request ) ); + } catch ( InvalidArgumentException $ex ) { + $message = $ex->getMessage(); + return new WP_Error( 'woocommerce_rest_invalid_argument', $message ? $message : __( 'Internal server error', 'woocommerce' ), array( 'status' => 400 ) ); + } catch ( Exception $ex ) { + wc_get_logger()->error( StringUtil::class_name_without_namespace( static::class ) . ": when executing method $method_name: {$ex->getMessage()}" ); + return $this->internal_wp_error( $ex ); + } + } + + /** + * Return an WP_Error object for an internal server error, with exception information if the current user is an admin. + * + * @param Exception $exception The exception to maybe include information from. + * @return WP_Error + */ + protected function internal_wp_error( Exception $exception ): WP_Error { + $data = array( 'status' => 500 ); + if ( current_user_can( 'manage_woocommerce' ) ) { + $data['exception_class'] = get_class( $exception ); + $data['exception_message'] = $exception->getMessage(); + $data['exception_trace'] = (array) $exception->getTrace(); + } + $data['exception_message'] = $exception->getMessage(); + + return new WP_Error( 'woocommerce_rest_internal_error', __( 'Internal server error', 'woocommerce' ), $data ); + } + + /** + * Permission check for REST API endpoints, given the request method. + * + * @param WP_REST_Request $request The request for which the permission is checked. + * @param string $required_capability_name The name of the required capability. + * @param mixed ...$extra_args Extra arguments to be used for the permission check. + * @return bool|WP_Error True if the current user has the capability, otherwise an "Unauthorized" error or False if no error is available for the request method. + */ + protected function check_permission( WP_REST_Request $request, string $required_capability_name, ...$extra_args ) { + if ( current_user_can( $required_capability_name, $extra_args ) ) { + return true; + } + + $error_information = $this->authentication_errors_by_method[ $request->get_method() ] ?? null; + if ( is_null( $error_information ) ) { + return false; + } + + return new WP_Error( + $error_information['code'], + $error_information['message'], + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Get the base schema for the REST API endpoints. + * + * @return array + */ + protected function get_base_schema(): array { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'order receipts', + 'type' => 'object', + ); + } +} diff --git a/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php b/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php index 0f46b8d1614..27157ed4471 100644 --- a/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php +++ b/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php @@ -54,7 +54,7 @@ class TransientFilesEngine implements RegisterHooksInterface { self::add_action( self::CLEANUP_ACTION_NAME, array( $this, 'handle_expired_files_cleanup_action' ) ); self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_debug_tools_entries' ), 999, 1 ); - self::add_action( 'init', array( $this, 'handle_init' ), 0 ); + self::add_action( 'init', array( $this, 'add_endpoint' ), 0 ); self::add_filter( 'query_vars', array( $this, 'handle_query_vars' ), 0 ); self::add_action( 'parse_request', array( $this, 'handle_parse_request' ), 0 ); } @@ -109,6 +109,14 @@ class TransientFilesEngine implements RegisterHooksInterface { if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) { throw new Exception( "Can't create directory: $transient_files_directory" ); } + + // Create infrastructure to prevent listing the contents of the transient files directory. + require_once ABSPATH . 'wp-admin/includes/file.php'; + \WP_Filesystem(); + $wp_filesystem = $this->legacy_proxy->get_global( 'wp_filesystem' ); + $wp_filesystem->put_contents( $transient_files_directory . '/.htaccess', 'deny from all' ); + $wp_filesystem->put_contents( $transient_files_directory . '/index.html', '' ); + $realpathed_transient_files_directory = $this->legacy_proxy->call_function( 'realpath', $transient_files_directory ); } else { throw new Exception( "The base transient files directory doesn't exist: $transient_files_directory" ); @@ -153,7 +161,8 @@ class TransientFilesEngine implements RegisterHooksInterface { } $filepath = $transient_files_directory . '/' . $filename; - WP_Filesystem(); + require_once ABSPATH . 'wp-admin/includes/file.php'; + \WP_Filesystem(); $wp_filesystem = $this->legacy_proxy->get_global( 'wp_filesystem' ); if ( false === $wp_filesystem->put_contents( $filepath, $file_contents ) ) { throw new Exception( "Can't create file: $filepath" ); @@ -175,6 +184,23 @@ class TransientFilesEngine implements RegisterHooksInterface { * @return string|null The full physical path of the file, or null if the files doesn't exist. */ public function get_transient_file_path( string $filename ): ?string { + $expiration_date = self::get_expiration_date( $filename ); + if ( is_null( $expiration_date ) ) { + return null; + } + + $file_path = $this->get_transient_files_directory() . '/' . $expiration_date . '/' . substr( $filename, 6 ); + + return is_file( $file_path ) ? $file_path : null; + } + + /** + * Get the expiration date of a transient file based on its file name. The actual existence of the file is NOT checked. + * + * @param string $filename The name of the transient file to get the expiration date for. + * @return string|null Expiration date formatted as Y-m-d, null if the file name isn't encoding a proper date. + */ + public static function get_expiration_date( string $filename ) : ?string { if ( strlen( $filename ) < 7 || ! ctype_xdigit( $filename ) ) { return null; } @@ -185,13 +211,18 @@ class TransientFilesEngine implements RegisterHooksInterface { hexdec( substr( $filename, 3, 1 ) ), hexdec( substr( $filename, 4, 2 ) ) ); - if ( ! TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ) { - return null; - } - $file_path = $this->get_transient_files_directory() . '/' . $expiration_date . '/' . substr( $filename, 6 ); + return TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ? $expiration_date : null; + } - return is_file( $file_path ) ? $file_path : null; + /** + * Get the public URL of a transient file. The file name is NOT checked for validity or actual existence. + * + * @param string $filename The name of the transient file to get the public URL for. + * @return string The public URL of the file. + */ + public function get_public_url( string $filename ) { + return $this->legacy_proxy->call_function( 'get_site_url', null, '/wc/file/transient/' . $filename ); } /** @@ -394,7 +425,7 @@ class TransientFilesEngine implements RegisterHooksInterface { /** * Handle the "init" action, add rewrite rules for the "wc/file" endpoint. */ - private function handle_init() { + public static function add_endpoint() { add_rewrite_rule( '^wc/file/transient/?$', 'index.php?wc-transient-file-name=', 'top' ); add_rewrite_rule( '^wc/file/transient/(.+)$', 'index.php?wc-transient-file-name=$matches[1]', 'top' ); add_rewrite_endpoint( 'wc/file/transient', EP_ALL ); diff --git a/plugins/woocommerce/tests/php/src/Internal/ReceiptRendering/ReceiptRenderingEngineTest.php b/plugins/woocommerce/tests/php/src/Internal/ReceiptRendering/ReceiptRenderingEngineTest.php new file mode 100644 index 00000000000..dcfce320b8d --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Internal/ReceiptRendering/ReceiptRenderingEngineTest.php @@ -0,0 +1,255 @@ +tfe_mock = $this->getMockBuilder( TransientFilesEngine::class )->getMock(); + $this->sut = new ReceiptRenderingEngine(); + $this->sut->init( $this->tfe_mock, wc_get_container()->get( LegacyProxy::class ) ); + } + + /** + * @testdox 'generate_receipt' returns null if the id of a not existing order is passed. + */ + public function test_generate_receipt_for_no_existing_order_returns_null() { + $filename = $this->sut->generate_receipt( -1 ); + $this->assertNull( $filename ); + } + + /** + * @testdox 'generate_receipt' returns the file name of an already existing receipt if 'force_new' is not true. + */ + public function test_generate_receipt_returns_existing_receipt_if_force_new_is_not_true() { + $order = OrderHelper::create_order(); + + $order->update_meta_data( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY, 'the_existing_receipt_filename' ); + + $this->tfe_mock + ->expects( self::never() ) + ->method( 'create_transient_file' ); + + $this->tfe_mock + ->expects( self::once() ) + ->method( 'get_transient_file_path' ) + ->with( 'the_existing_receipt_filename' ) + ->willReturn( 'transient_files/the_existing_receipt_filename' ); + + $filename = $this->sut->generate_receipt( $order ); + $this->assertEquals( 'the_existing_receipt_filename', $filename ); + + $meta = $order->get_meta( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY ); + $this->assertEquals( 'the_existing_receipt_filename', $meta ); + } + + /** + * @testdox 'generate_receipt' returns the file name of a new receipt if 'force_new' is not true but no receipt file exists. + */ + public function test_generate_receipt_returns_new_receipt_file_if_no_receipt_exists_and_force_new_is_not_true() { + $this->register_legacy_proxy_function_mocks( + array( + 'strtotime' => fn ( $arg) => '+1 days' === $arg ? -1 : strtotime( $arg ), + 'gmdate' => fn ( $format, $value) => 'Y-m-d' === $format && -1 === $value ? '2999-12-31' : gmdate( $format, $value ), + ) + ); + + $order = OrderHelper::create_order(); + + $this->tfe_mock + ->expects( self::once() ) + ->method( 'create_transient_file' ) + ->with( self::isType( 'string' ), self::equalTo( '2999-12-31' ) ) + ->willReturn( 'the_generated_file_name' ); + + $filename = $this->sut->generate_receipt( $order ); + $this->assertEquals( 'the_generated_file_name', $filename ); + + $meta = $order->get_meta( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY ); + $this->assertEquals( 'the_generated_file_name', $meta ); + } + + /** + * @testdox 'generate_receipt' returns the file name of a new receipt if a receipt file already exists but 'force_new' is true. + */ + public function test_generate_receipt_returns_new_receipt_file_if_receipt_exists_but_force_new_is_true() { + $this->register_legacy_proxy_function_mocks( + array( + 'strtotime' => fn ( $arg) => '+1 days' === $arg ? -1 : strtotime( $arg ), + 'gmdate' => fn ( $format, $value) => 'Y-m-d' === $format && -1 === $value ? '2999-12-31' : gmdate( $format, $value ), + ) + ); + + $order = OrderHelper::create_order(); + + $order->update_meta_data( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY, 'the_existing_receipt_filename' ); + + $this->tfe_mock + ->expects( self::never() ) + ->method( 'get_transient_file_path' ); + + $this->tfe_mock + ->expects( self::once() ) + ->method( 'create_transient_file' ) + ->with( self::isType( 'string' ), self::equalTo( '2999-12-31' ) ) + ->willReturn( 'the_generated_file_name' ); + + $filename = $this->sut->generate_receipt( $order, null, true ); + $this->assertEquals( 'the_generated_file_name', $filename ); + + $meta = $order->get_meta( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY ); + $this->assertEquals( 'the_generated_file_name', $meta ); + } + + /** + * @testdox 'generate_receipt' uses the supplied expiration date to create the transient file. + */ + public function test_generate_receipt_with_custom_expiration_date() { + $order = OrderHelper::create_order(); + + $this->tfe_mock + ->expects( self::once() ) + ->method( 'create_transient_file' ) + ->with( self::isType( 'string' ), self::equalTo( '2888-10-20' ) ) + ->willReturn( 'the_generated_file_name' ); + + $filename = $this->sut->generate_receipt( $order, '2888-10-20' ); + $this->assertEquals( 'the_generated_file_name', $filename ); + + $meta = $order->get_meta( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY ); + $this->assertEquals( 'the_generated_file_name', $meta ); + } + + /** + * @testdox 'generate_receipt' throws an exception if an invalid expiration date is supplied. + * + * @testWith ["NOT_A_DATE"] + * ["2000-01-01"] + * ["2999-34-89"] + * + * @param string $expiration_date The expiration date to test. + */ + public function test_generate_receipt_throws_for_bad_expiration_date( string $expiration_date ) { + $this->expectException( \InvalidArgumentException::class ); + + $sut = wc_get_container()->get( ReceiptRenderingEngine::class ); + + $order = OrderHelper::create_order(); + $sut->generate_receipt( $order, $expiration_date ); + } + + /** + * @testdox 'get_existing_receipt' returns null if the id of a not existing order is passed. + */ + public function test_get_existing_receipt_for_no_existing_order_returns_null() { + $filename = $this->sut->get_existing_receipt( -1 ); + $this->assertNull( $filename ); + } + + /** + * @testdox 'get_existing_receipt' returns the file name of an existing receipt if the appropriate order meta entry exists and the file actually exists too and has not expired. + */ + public function test_get_existing_receipt_returns_existing_receipt() { + $order = OrderHelper::create_order(); + + $order->update_meta_data( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY, 'the_existing_receipt_filename' ); + + $this->tfe_mock + ->expects( self::once() ) + ->method( 'get_transient_file_path' ) + ->with( 'the_existing_receipt_filename' ) + ->willReturn( 'transient_files/the_existing_receipt_filename' ); + + $this->tfe_mock + ->expects( self::once() ) + ->method( 'file_has_expired' ) + ->with( 'transient_files/the_existing_receipt_filename' ) + ->willReturn( false ); + + $filename = $this->sut->get_existing_receipt( $order ); + $this->assertEquals( 'the_existing_receipt_filename', $filename ); + } + + /** + * @testdox 'get_existing_receipt' returns the file name of an existing receipt if the appropriate order meta entry doesn't exist. + */ + public function test_get_existing_receipt_returns_null_if_no_meta_entry() { + $order = OrderHelper::create_order(); + + $this->tfe_mock + ->expects( self::never() ) + ->method( 'get_transient_file_path' ); + + $filename = $this->sut->get_existing_receipt( $order ); + $this->assertNull( $filename ); + } + + /** + * @testdox 'get_existing_receipt' returns the file name of an existing receipt if the appropriate order meta entry exists but the file doesn't. + */ + public function test_get_existing_receipt_returns_null_if_meta_entry_exists_but_file_doesnt() { + $order = OrderHelper::create_order(); + + $order->update_meta_data( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY, 'the_existing_receipt_filename' ); + + $this->tfe_mock + ->expects( self::once() ) + ->method( 'get_transient_file_path' ) + ->with( 'the_existing_receipt_filename' ) + ->willReturn( null ); + + $filename = $this->sut->get_existing_receipt( $order ); + $this->assertNull( $filename ); + } + + /** + * @testdox 'get_existing_receipt' returns the file name of an existing receipt if the appropriate order meta entry exists and the file exists but has expired. + */ + public function test_get_existing_receipt_returns_null_if_meta_entry_exists_but_file_has_expired() { + $order = OrderHelper::create_order(); + + $order->update_meta_data( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY, 'the_existing_receipt_filename' ); + + $this->tfe_mock + ->expects( self::once() ) + ->method( 'get_transient_file_path' ) + ->with( 'the_existing_receipt_filename' ) + ->willReturn( 'transient_files/the_existing_receipt_filename' ); + + $this->tfe_mock + ->expects( self::once() ) + ->method( 'file_has_expired' ) + ->with( 'transient_files/the_existing_receipt_filename' ) + ->willReturn( true ); + + $filename = $this->sut->get_existing_receipt( $order ); + $this->assertNull( $filename ); + } +} diff --git a/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php b/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php index c614f5a6a90..d774074fdec 100644 --- a/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php +++ b/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php @@ -258,8 +258,31 @@ class TransientFilesEngineTest extends \WC_REST_Unit_Test_Case { ) ); + // phpcs:disable Squiz.Commenting + $fake_wp_filesystem = new class() { + public $created_files = array(); + + public function put_contents( string $file, string $contents, $mode = false ): bool { + $this->created_files[ $file ] = $contents; + return strlen( $contents ); + } + }; + // phpcs:enable Squiz.Commenting + + $this->register_legacy_proxy_global_mocks( + array( + 'wp_filesystem' => $fake_wp_filesystem, + ) + ); + $this->sut->get_transient_files_directory(); $this->assertEquals( '/wordpress/uploads/woocommerce_transient_files', $created_dir ); + + $expected_created_files = array( + '/wordpress/uploads/woocommerce_transient_files/.htaccess' => 'deny from all', + '/wordpress/uploads/woocommerce_transient_files/index.html' => '', + ); + $this->assertEquals( $expected_created_files, $fake_wp_filesystem->created_files ); } /** @@ -301,6 +324,42 @@ class TransientFilesEngineTest extends \WC_REST_Unit_Test_Case { $this->assertEquals( static::$transient_files_dir . '/2023-12-02/000102030405060708090a0b0c0d0e0f', $result ); } + /** + * @testdox get_expiration_date returns null for file names without a properly encoded expiration date. + * + * @testWith [""] + * ["123"] + * ["NOT_HEX_DATE112233"] + * ["7e8f01"] + * + * @param string $filename Filename to test. + */ + public function test_get_expiration_date_returns_null_for_wrongly_formatted_date( string $filename ) { + $this->assertNull( TransientFilesEngine::get_expiration_date( $filename ) ); + } + + /** + * @testdox get_expiration_date returns the date encoded in a proper transient file name. + */ + public function test_get_expiration_date_correctly_extracts_date_from_filename() { + $actual = TransientFilesEngine::get_expiration_date( '7e821b00000' ); + $this->assertEquals( '2024-02-27', $actual ); + } + + /** + * @testdox get_public_url returns the full public URL of a transient file given its name. + */ + public function test_get_public_url() { + $this->register_legacy_proxy_function_mocks( + array( + 'get_site_url' => fn( $blog_id, $path) => 'http://example.com' . $path, + ) + ); + + $actual = $this->sut->get_public_url( '1234abcd' ); + $this->assertEquals( 'http://example.com/wc/file/transient/1234abcd', $actual ); + } + /** * @testdox file_has_expired return false for a file that hasn't expired. *