Commit 6c6e79ed6e0 for woocommerce
commit 6c6e79ed6e0154d98aa8deb1632539120ce9071c
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Thu Nov 5 22:10:54 2026 +0300
Add fulfillment data to personal data export and erasure (#69796)
diff --git a/plugins/woocommerce/changelog/69796-wooplug-7977-fulfillment-privacy-export-erase b/plugins/woocommerce/changelog/69796-wooplug-7977-fulfillment-privacy-export-erase
new file mode 100644
index 00000000000..956bdc5c3bf
--- /dev/null
+++ b/plugins/woocommerce/changelog/69796-wooplug-7977-fulfillment-privacy-export-erase
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Include fulfillment tracking data in WordPress personal data exports, and redact it when an order is anonymized.
diff --git a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
index b3ea6d7b19e..054db2053ca 100644
--- a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
+++ b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
@@ -710,6 +710,32 @@ function customize_provider_label( $label, $fulfillment ) {
}
```
+### Privacy Filters
+
+#### `woocommerce_privacy_remove_fulfillment_personal_data_meta`
+
+Filters the fulfillment meta keys cleared when an order is anonymized through the WordPress personal-data eraser or the automatic retention cleanup. It also runs on a store where the fulfillments feature has since been disabled, as long as its tables were created while it was on, since any tracking data they hold is still stored.
+
+**File:** `src/Admin/Features/Fulfillments/FulfillmentsPrivacy.php`
+
+**Parameters:**
+
+- `$meta_keys` (string[]) - The meta keys to clear. Defaults to `_tracking_number` and `_tracking_url`
+- `$order` (WC_Order) - The order being anonymized
+
+**Return Value:** The list of meta keys to clear.
+
+**Purpose:** Add extension-stored personal data (for example a shipping label URL or an internal shipment ID) to the keys redacted during erasure.
+
+```php
+add_filter( 'woocommerce_privacy_remove_fulfillment_personal_data_meta', 'redact_custom_fulfillment_meta', 10, 2 );
+
+function redact_custom_fulfillment_meta( $meta_keys, $order ) {
+ $meta_keys[] = '_shipping_label_url';
+ return $meta_keys;
+}
+```
+
## Best Practices
### Hook Priority and Execution Order
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
index 3723b1ddf15..016eb6499ca 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsController.php
@@ -22,6 +22,7 @@ class FulfillmentsController {
FulfillmentsRenderer::class,
FulfillmentsSettings::class,
OrderFulfillmentsRestController::class,
+ FulfillmentsPrivacy::class,
);
/**
@@ -52,7 +53,11 @@ class FulfillmentsController {
// because the woocommerce_data_stores filter can fire before the 'init' action
// (e.g. from WC Payments during 'plugins_loaded'), and feature_is_enabled() would
// trigger translation loading too early, causing _load_textdomain_just_in_time warnings.
- if ( 'yes' !== get_option( 'woocommerce_feature_fulfillments_enabled', 'no' ) ) {
+ $feature_enabled = 'yes' === get_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+
+ // A store that created fulfillments and then disabled the feature still holds that
+ // data, so the store stays registered for the privacy export and eraser to read it.
+ if ( ! $feature_enabled && ! get_option( 'woocommerce_fulfillments_db_tables_created', false ) ) {
return $data_stores;
}
@@ -69,6 +74,12 @@ class FulfillmentsController {
// If fulfillments feature is not enabled, do not add the DB tables, and don't register the controller.
if ( ! $features_controller->feature_is_enabled( 'fulfillments' ) ) {
+ // A store that created fulfillments before the feature was disabled still holds
+ // tracking data that can resolve to a delivery address, so both export and
+ // erasure keep working.
+ if ( get_option( 'woocommerce_fulfillments_db_tables_created', false ) ) {
+ $container->get( FulfillmentsPrivacy::class )->register();
+ }
return;
}
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsPrivacy.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsPrivacy.php
new file mode 100644
index 00000000000..d3a2f9dc5ff
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsPrivacy.php
@@ -0,0 +1,332 @@
+<?php declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Admin\Features\Fulfillments;
+
+use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore;
+use WC_Data_Store;
+use WC_Order;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Wires fulfillment (shipment tracking) data into the WordPress personal-data
+ * export and erasure framework.
+ *
+ * Export appends the customer's fulfillment records onto each order in their
+ * personal-data export, through the core order exporter.
+ * Erasure redacts the tracking meta whenever an order is anonymized, which covers
+ * both the admin "Erase Personal Data" tool and the automatic retention cleanup,
+ * since both route through {@see \WC_Privacy_Erasers::remove_order_personal_data()}.
+ *
+ * Redaction writes directly to the core fulfillment tables, and the export asks the data
+ * store for soft-deleted records, which FulfillmentsDataStoreInterface does not declare. So a
+ * store swapped in through the `woocommerce_data_stores` filter owns its own erasure and
+ * whatever it returns to the export.
+ *
+ * @since 11.4.0
+ */
+class FulfillmentsPrivacy {
+
+ /**
+ * Register the privacy hooks.
+ *
+ * @since 11.4.0
+ */
+ public function register(): void {
+ add_filter( 'woocommerce_privacy_export_order_personal_data', array( $this, 'export_order_fulfillment_data' ), 10, 2 );
+ add_action( 'woocommerce_privacy_remove_order_personal_data', array( $this, 'remove_fulfillment_personal_data' ) );
+ }
+
+ /**
+ * Whether this store ever created the fulfillment tables.
+ *
+ * Reads the marker option rather than probing with SHOW TABLES, which carries no table
+ * name for a db-routing drop-in to route on and so can be answered by the wrong dataset.
+ *
+ * @return bool
+ */
+ private function tables_exist(): bool {
+ return (bool) get_option( 'woocommerce_fulfillments_db_tables_created', false );
+ }
+
+ /**
+ * Append an order's fulfillment tracking data to its personal-data export entry.
+ *
+ * Hooked onto the core order exporter rather than registered as a standalone exporter,
+ * and each shipment is emitted as a single row. Core reloads and rewrites the whole
+ * accumulated export on every page, so both the item count and the row count are paid
+ * again on each remaining page, and a large order history exhausts memory.
+ *
+ * @since 11.4.0
+ *
+ * @param mixed $personal_data Name/value rows the core exporter built for the order.
+ * @param mixed $order The order being exported.
+ * @return array
+ */
+ public function export_order_fulfillment_data( $personal_data, $order ): array {
+ $personal_data = is_array( $personal_data ) ? $personal_data : array();
+
+ if ( ! $order instanceof WC_Order ) {
+ return $personal_data;
+ }
+
+ // Nothing to read without the tables, and on a store with the feature off the data
+ // store is registered only because they exist.
+ if ( ! $this->tables_exist() ) {
+ return $personal_data;
+ }
+
+ // A read failure here must not abort the customer's whole personal-data export, which
+ // spans every extension's order data, so it is logged and this order's fulfillment rows
+ // are skipped. Core reports the request complete either way, so the log line is the
+ // only signal a failure leaves.
+ try {
+ $fulfillments = $this->read_order_fulfillments( $order );
+
+ $shipments = array();
+
+ foreach ( $fulfillments as $fulfillment ) {
+ // Defensive: a fulfillment saved through the data store always has a valid
+ // status, so it always yields at least one field. A malformed stored row
+ // might not, and must not take a shipment number with it.
+ $rows = $this->get_fulfillment_personal_data( $fulfillment, $order );
+ if ( empty( $rows ) ) {
+ continue;
+ }
+
+ // Values are joined with plain punctuation rather than a translatable format
+ // string: a locale that lost a placeholder would delete exported data.
+ $fields = array();
+ foreach ( $rows as $row ) {
+ $fields[] = $row['name'] . ': ' . $row['value'];
+ }
+
+ $shipments[] = implode( '; ', $fields );
+ }
+
+ // Several shipments share the one order entry, so number them to tell them apart,
+ // off the shipments that produced fields so the export shows neither a gap nor a
+ // number implying one it left out.
+ $numbered = count( $shipments ) > 1;
+ $shipment_rows = array();
+
+ foreach ( $shipments as $index => $shipment ) {
+ $shipment_rows[] = array(
+ 'name' => $numbered
+ /* translators: %s: shipment number within the order. */
+ ? sprintf( _x( 'Shipment %s', 'fulfillment export field', 'woocommerce' ), number_format_i18n( $index + 1 ) )
+ : _x( 'Shipment', 'fulfillment export field', 'woocommerce' ),
+ 'value' => $shipment,
+ );
+ }
+
+ // Appended in one go, so a failure part way through cannot leave the order half
+ // exported while the request still reports it complete.
+ $personal_data = array_merge( $personal_data, $shipment_rows );
+ } catch ( \Throwable $e ) {
+ wc_get_logger()->error(
+ 'Failed to export fulfillment personal data for order ' . $order->get_id() . ': ' . $e->getMessage(),
+ array( 'source' => 'fulfillments' )
+ );
+ }
+
+ return $personal_data;
+ }
+
+ /**
+ * Read every fulfillment of an order, including soft-deleted ones.
+ *
+ * Soft-deleted records are included because their tracking meta is retained and must be
+ * covered by the access request, mirroring what the eraser clears.
+ *
+ * @param WC_Order $order The order being exported.
+ * @return Fulfillment[]
+ * @throws \Exception If the read left a database error behind.
+ */
+ private function read_order_fulfillments( WC_Order $order ): array {
+ /**
+ * Fulfillments data store.
+ *
+ * @var FulfillmentsDataStore $datastore
+ */
+ $datastore = WC_Data_Store::load( 'order-fulfillment' );
+
+ global $wpdb;
+
+ // A replacement store can answer without querying, which would leave an unrelated
+ // error in place and drop this order's rows for no reason. flush() is what wpdb runs
+ // at the start of every query anyway.
+ $wpdb->flush();
+
+ $fulfillments = $datastore->read_fulfillments( WC_Order::class, (string) $order->get_id(), true );
+
+ // get_results() returns an empty array on a failed query, so a database error is
+ // otherwise indistinguishable from "no fulfillments" and would be reported as a
+ // complete export. wpdb resets the error on every query, so this sees the fulfillment
+ // read and the last meta read, not an earlier meta read a later one masked.
+ if ( '' !== (string) $wpdb->last_error ) {
+ throw new \Exception( esc_html( 'Failed to read fulfillment data: ' . $wpdb->last_error ) );
+ }
+
+ return $fulfillments;
+ }
+
+ /**
+ * Build the exportable fields for a single fulfillment.
+ *
+ * The order's items cover everything purchased, but an order can be split across
+ * several fulfillments, so the shipment-level breakdown (which items shipped where,
+ * and with what tracking) is not otherwise in the export. The labels are read inside
+ * a row already named for the shipment, so they stay short.
+ *
+ * @param Fulfillment $fulfillment The fulfillment.
+ * @param WC_Order $order The order the fulfillment belongs to.
+ * @return array<int, array{name: string, value: string}>
+ */
+ private function get_fulfillment_personal_data( Fulfillment $fulfillment, WC_Order $order ): array {
+ $data = array();
+
+ $status = (string) $fulfillment->get_status();
+ if ( '' !== $status ) {
+ $statuses = FulfillmentUtils::get_fulfillment_statuses();
+ $label = $statuses[ $status ]['label'] ?? $status;
+ $data[] = array(
+ 'name' => _x( 'Status', 'fulfillment export field', 'woocommerce' ),
+ 'value' => is_scalar( $label ) ? (string) $label : $status,
+ );
+ }
+
+ $item_names = array();
+ foreach ( $fulfillment->get_items() as $item ) {
+ if ( ! is_array( $item ) ) {
+ continue;
+ }
+ $item_id = isset( $item['item_id'] ) ? (int) $item['item_id'] : 0;
+ $order_item = $item_id > 0 ? $order->get_item( $item_id, false ) : false;
+ if ( $order_item ) {
+ $quantity = isset( $item['qty'] ) ? (int) $item['qty'] : 0;
+ $item_names[] = $order_item->get_name() . ' x ' . $quantity;
+ }
+ }
+ if ( ! empty( $item_names ) ) {
+ $data[] = array(
+ 'name' => _x( 'Items', 'fulfillment export field', 'woocommerce' ),
+ 'value' => implode( ', ', $item_names ),
+ );
+ }
+
+ $tracking_number = $fulfillment->get_tracking_number();
+ if ( ! empty( $tracking_number ) ) {
+ $data[] = array(
+ 'name' => _x( 'Tracking number', 'fulfillment export field', 'woocommerce' ),
+ 'value' => $tracking_number,
+ );
+ }
+
+ $provider = FulfillmentUtils::get_provider_display_name( $fulfillment );
+ if ( '' !== $provider ) {
+ $data[] = array(
+ 'name' => _x( 'Provider', 'fulfillment export field', 'woocommerce' ),
+ 'value' => $provider,
+ );
+ }
+
+ $tracking_url = $fulfillment->get_tracking_url();
+ if ( ! empty( $tracking_url ) ) {
+ $data[] = array(
+ 'name' => _x( 'Tracking URL', 'fulfillment export field', 'woocommerce' ),
+ 'value' => $tracking_url,
+ );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Redact fulfillment tracking data for an order being anonymized.
+ *
+ * Includes soft-deleted fulfillments, since their tracking meta is retained in
+ * the database and a tracking number can be resolved to a delivery address.
+ *
+ * @since 11.4.0
+ *
+ * @param mixed $order The order being anonymized.
+ */
+ public function remove_fulfillment_personal_data( $order ): void {
+ if ( ! $order instanceof WC_Order ) {
+ return;
+ }
+
+ // Nearly every store with the feature off never created these tables, so skip those
+ // instead of failing a query for each order anonymized.
+ if ( ! $this->tables_exist() ) {
+ return;
+ }
+
+ /**
+ * Filters the fulfillment meta keys redacted during personal-data erasure.
+ *
+ * @since 11.4.0
+ *
+ * @param string[] $meta_keys The meta keys to clear.
+ * @param WC_Order $order The order being anonymized.
+ */
+ $meta_keys = apply_filters(
+ 'woocommerce_privacy_remove_fulfillment_personal_data_meta',
+ array( '_tracking_number', '_tracking_url' ),
+ $order
+ );
+
+ if ( ! is_array( $meta_keys ) ) {
+ return;
+ }
+
+ $meta_keys = array_values(
+ array_unique(
+ array_filter(
+ array_map( 'strval', array_filter( $meta_keys, 'is_scalar' ) ),
+ function ( string $key ): bool {
+ return '' !== $key;
+ }
+ )
+ )
+ );
+ if ( empty( $meta_keys ) ) {
+ return;
+ }
+
+ global $wpdb;
+
+ // Redact the tracking meta for every fulfillment of the order, including soft-deleted
+ // records. The data store keeps deleted fulfillments immutable, but their retained
+ // tracking number can still be resolved to a delivery address, so it must be cleared
+ // directly. Values are stored JSON-encoded, so an empty string is written as `""`.
+ $placeholders = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );
+ $params = array_merge(
+ array( wp_json_encode( '' ), WC_Order::class, (string) $order->get_id() ),
+ $meta_keys
+ );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $result = $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$wpdb->prefix}wc_order_fulfillment_meta AS m
+ INNER JOIN {$wpdb->prefix}wc_order_fulfillments AS f ON f.fulfillment_id = m.fulfillment_id
+ SET m.meta_value = %s
+ WHERE f.entity_type = %s AND f.entity_id = %s AND m.meta_key IN ( {$placeholders} )",
+ $params
+ )
+ );
+ // phpcs:enable
+
+ // The erasure action cannot report failure back to the request, so a failed redaction
+ // would otherwise leave tracking in place while the order reads as anonymized. Zero rows
+ // is not a failure, so only false is logged.
+ if ( false === $result ) {
+ wc_get_logger()->error(
+ 'Failed to redact fulfillment tracking for order ' . $order->get_id() . ': ' . $wpdb->last_error,
+ array( 'source' => 'fulfillments' )
+ );
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsControllerTest.php
index c94d5ba20e4..5753488de64 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsControllerTest.php
@@ -5,6 +5,8 @@ namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;
use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore;
use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsPrivacy;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
use WC_Unit_Test_Case;
/**
@@ -26,12 +28,20 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
*/
private $original_fulfillments_flag;
+ /**
+ * Original value of the fulfillment tables marker.
+ *
+ * @var mixed
+ */
+ private $original_tables_created;
+
/**
* Set up test fixtures.
*/
public function setUp(): void {
parent::setUp();
$this->original_fulfillments_flag = get_option( 'woocommerce_feature_fulfillments_enabled' );
+ $this->original_tables_created = get_option( 'woocommerce_fulfillments_db_tables_created' );
$this->sut = wc_get_container()->get( FulfillmentsController::class );
}
@@ -44,6 +54,11 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
} else {
update_option( 'woocommerce_feature_fulfillments_enabled', $this->original_fulfillments_flag );
}
+ if ( false === $this->original_tables_created ) {
+ delete_option( 'woocommerce_fulfillments_db_tables_created' );
+ } else {
+ update_option( 'woocommerce_fulfillments_db_tables_created', $this->original_tables_created );
+ }
parent::tearDown();
}
@@ -60,16 +75,34 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Should not register the order-fulfillment data store when feature is disabled.
+ * @testdox Should not register the order-fulfillment data store when the feature is disabled and the tables were never created.
*/
public function test_register_data_stores_when_feature_disabled(): void {
update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+ delete_option( 'woocommerce_fulfillments_db_tables_created' );
$result = $this->sut->register_data_stores( array() );
$this->assertArrayNotHasKey( 'order-fulfillment', $result, 'Data store should not be registered when feature is disabled' );
}
+ /**
+ * @testdox Should register the order-fulfillment data store when the feature is disabled but its tables exist.
+ */
+ public function test_register_data_stores_when_feature_disabled_but_tables_exist(): void {
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+ update_option( 'woocommerce_fulfillments_db_tables_created', true );
+
+ $result = $this->sut->register_data_stores( array() );
+
+ $this->assertArrayHasKey(
+ 'order-fulfillment',
+ $result,
+ 'Fulfillment data left behind by the disabled feature still has to be readable for the privacy export'
+ );
+ $this->assertSame( FulfillmentsDataStore::class, $result['order-fulfillment'] );
+ }
+
/**
* @testdox Should preserve existing data stores when feature is enabled.
*/
@@ -89,6 +122,7 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
*/
public function test_register_data_stores_preserves_existing_stores_when_disabled(): void {
update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+ delete_option( 'woocommerce_fulfillments_db_tables_created' );
$existing = array( 'some-store' => 'SomeStoreClass' );
$result = $this->sut->register_data_stores( $existing );
@@ -97,6 +131,68 @@ class FulfillmentsControllerTest extends WC_Unit_Test_Case {
$this->assertArrayNotHasKey( 'order-fulfillment', $result );
}
+ /**
+ * @testdox Should keep both privacy hooks registered when the feature is disabled.
+ */
+ public function test_initialize_fulfillments_registers_privacy_hooks_when_feature_disabled(): void {
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+ update_option( 'woocommerce_fulfillments_db_tables_created', true );
+
+ // Both branches of initialize_fulfillments() register these hooks, so confirm which one
+ // is under test rather than letting the enabled branch satisfy the assertions.
+ $this->assertFalse(
+ wc_get_container()->get( FeaturesController::class )->feature_is_enabled( 'fulfillments' ),
+ 'This test covers the disabled branch.'
+ );
+
+ // The container instance is shared, so clear both hooks before asserting that the
+ // disabled path puts them back.
+ $privacy = wc_get_container()->get( FulfillmentsPrivacy::class );
+ remove_action( 'woocommerce_privacy_remove_order_personal_data', array( $privacy, 'remove_fulfillment_personal_data' ) );
+ remove_filter( 'woocommerce_privacy_export_order_personal_data', array( $privacy, 'export_order_fulfillment_data' ) );
+
+ $this->sut->initialize_fulfillments();
+
+ $this->assertNotFalse(
+ has_action( 'woocommerce_privacy_remove_order_personal_data', array( $privacy, 'remove_fulfillment_personal_data' ) ),
+ 'Tracking data left behind by the disabled feature should still be erasable'
+ );
+ $this->assertNotFalse(
+ has_filter( 'woocommerce_privacy_export_order_personal_data', array( $privacy, 'export_order_fulfillment_data' ) ),
+ 'An access request should still return tracking data the disabled feature left behind'
+ );
+ }
+
+ /**
+ * @testdox Should register no privacy hooks when the feature is disabled and the tables were never created.
+ */
+ public function test_initialize_fulfillments_registers_no_privacy_hooks_without_tables(): void {
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+ delete_option( 'woocommerce_fulfillments_db_tables_created' );
+
+ // Without the marker the enabled branch would drop and recreate the fulfillment tables,
+ // and DDL commits implicitly, so the base class transaction could not roll it back.
+ $this->assertFalse(
+ wc_get_container()->get( FeaturesController::class )->feature_is_enabled( 'fulfillments' ),
+ 'This test covers the disabled branch.'
+ );
+
+ $privacy = wc_get_container()->get( FulfillmentsPrivacy::class );
+ remove_action( 'woocommerce_privacy_remove_order_personal_data', array( $privacy, 'remove_fulfillment_personal_data' ) );
+ remove_filter( 'woocommerce_privacy_export_order_personal_data', array( $privacy, 'export_order_fulfillment_data' ) );
+
+ $this->sut->initialize_fulfillments();
+
+ $this->assertFalse(
+ has_action( 'woocommerce_privacy_remove_order_personal_data', array( $privacy, 'remove_fulfillment_personal_data' ) ),
+ 'A store that never created the tables has no fulfillment data to erase'
+ );
+ $this->assertFalse(
+ has_filter( 'woocommerce_privacy_export_order_personal_data', array( $privacy, 'export_order_fulfillment_data' ) ),
+ 'A store that never created the tables has no fulfillment data to export'
+ );
+ }
+
/**
* @testdox Should return non-array input unchanged.
*/
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsPrivacyTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsPrivacyTest.php
new file mode 100644
index 00000000000..95bdff0c82a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsPrivacyTest.php
@@ -0,0 +1,623 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;
+
+use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDataStore;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Fulfillment;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsPrivacy;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Helpers\FulfillmentsHelper;
+use WC_Data_Store;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the FulfillmentsPrivacy class.
+ */
+class FulfillmentsPrivacyTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var FulfillmentsPrivacy
+ */
+ private $sut;
+
+ /**
+ * Original value of the fulfillments feature flag.
+ *
+ * @var mixed
+ */
+ private static $original_fulfillments_flag;
+
+ /**
+ * Enable the fulfillments feature and register its data store.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+ self::$original_fulfillments_flag = get_option( 'woocommerce_feature_fulfillments_enabled' );
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'yes' );
+ $controller = wc_get_container()->get( \Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController::class );
+ $controller->register();
+ $controller->initialize_fulfillments();
+ }
+
+ /**
+ * Restore the fulfillments feature flag.
+ */
+ public static function tearDownAfterClass(): void {
+ if ( false === self::$original_fulfillments_flag ) {
+ delete_option( 'woocommerce_feature_fulfillments_enabled' );
+ } else {
+ update_option( 'woocommerce_feature_fulfillments_enabled', self::$original_fulfillments_flag );
+ }
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new FulfillmentsPrivacy();
+ }
+
+ /**
+ * Create an order with a fulfillment carrying the given tracking meta.
+ *
+ * @param array $meta Fulfillment meta.
+ * @return array{0: WC_Order, 1: Fulfillment}
+ */
+ private function create_order_with_fulfillment( array $meta ): array {
+ $order = OrderHelper::create_order();
+ $order_items = $order->get_items();
+ $order_item = reset( $order_items );
+ $item_id = $order_item ? $order_item->get_id() : 1;
+
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_id' => (string) $order->get_id(),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ ),
+ array_merge(
+ array(
+ '_items' => array(
+ array(
+ 'item_id' => $item_id,
+ 'qty' => 1,
+ ),
+ ),
+ ),
+ $meta
+ )
+ );
+
+ return array( $order, $fulfillment );
+ }
+
+ /**
+ * Reload a fulfillment by ID, including soft-deleted records.
+ *
+ * @param int $order_id The order ID.
+ * @param int $fulfillment_id The fulfillment ID.
+ * @return Fulfillment|null
+ */
+ private function reload_fulfillment( int $order_id, int $fulfillment_id ): ?Fulfillment {
+ $datastore = WC_Data_Store::load( 'order-fulfillment' );
+ $fulfillments = $datastore->read_fulfillments( WC_Order::class, (string) $order_id, true );
+
+ foreach ( $fulfillments as $fulfillment ) {
+ if ( $fulfillment->get_id() === $fulfillment_id ) {
+ return $fulfillment;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @testdox register() hooks the order exporter and the order anonymization eraser.
+ */
+ public function test_register_hooks_order_exporter_and_eraser(): void {
+ $this->sut->register();
+
+ $this->assertNotFalse(
+ has_filter( 'woocommerce_privacy_export_order_personal_data', array( $this->sut, 'export_order_fulfillment_data' ) ),
+ 'The order exporter filter should be hooked.'
+ );
+ $this->assertNotFalse(
+ has_action( 'woocommerce_privacy_remove_order_personal_data', array( $this->sut, 'remove_fulfillment_personal_data' ) ),
+ 'The eraser should hook order anonymization.'
+ );
+ }
+
+ /**
+ * @testdox The order export does not fake a complete export when the fulfillment read fails.
+ */
+ public function test_export_skips_order_when_read_fails(): void {
+ $order = new WC_Order();
+ $order->save();
+
+ // Swap in a data store whose read throws. Extends the real store so it passes
+ // WC_Data_Store validation.
+ add_filter(
+ 'woocommerce_order-fulfillment_data_store',
+ function () {
+ return new class() extends FulfillmentsDataStore {
+ /**
+ * Fail the read the exporter performs.
+ *
+ * @param string $entity_type The entity type.
+ * @param string $entity_id The entity ID.
+ * @param bool $with_deleted Whether to include deleted fulfillments.
+ * @return array
+ * @throws \Exception When deleted records are requested, as the exporter does.
+ */
+ public function read_fulfillments( string $entity_type, string $entity_id, bool $with_deleted = false ): array {
+ if ( $with_deleted ) {
+ throw new \Exception( 'Simulated read failure.' );
+ }
+ return array();
+ }
+ };
+ }
+ );
+
+ $existing = array(
+ array(
+ 'name' => 'Order number',
+ 'value' => (string) $order->get_id(),
+ ),
+ );
+
+ $result = $this->sut->export_order_fulfillment_data( $existing, $order );
+
+ // The order's own rows survive and no fulfillment rows are appended from the failed read.
+ $this->assertSame( $existing, $result );
+ }
+
+ /**
+ * @testdox The order export does not fake a complete export when the fulfillment query errors.
+ */
+ public function test_export_skips_order_when_the_query_errors(): void {
+ $order = new WC_Order();
+ $order->save();
+
+ // get_results() returns an empty array on a failed query, so mimic that rather than a
+ // throw: the error only shows up in $wpdb->last_error.
+ add_filter(
+ 'woocommerce_order-fulfillment_data_store',
+ function () {
+ return new class() extends FulfillmentsDataStore {
+ /**
+ * Return nothing, leaving a DB error behind as a failed query does.
+ *
+ * @param string $entity_type The entity type.
+ * @param string $entity_id The entity ID.
+ * @param bool $with_deleted Whether to include deleted fulfillments.
+ * @return array
+ */
+ public function read_fulfillments( string $entity_type, string $entity_id, bool $with_deleted = false ): array {
+ global $wpdb;
+ $wpdb->last_error = 'Table \'wp_wc_order_fulfillment_meta\' doesn\'t exist';
+ return array();
+ }
+ };
+ }
+ );
+
+ $existing = array(
+ array(
+ 'name' => 'Order number',
+ 'value' => (string) $order->get_id(),
+ ),
+ );
+
+ global $wpdb;
+ try {
+ $result = $this->sut->export_order_fulfillment_data( $existing, $order );
+ } finally {
+ $wpdb->last_error = '';
+ }
+
+ $this->assertSame( $existing, $result, 'No fulfillment rows should be appended from an errored read.' );
+ }
+
+ /**
+ * @testdox The order export includes the customer's fulfillment tracking data.
+ */
+ public function test_export_includes_fulfillment_tracking_data(): void {
+ list( $order ) = $this->create_order_with_fulfillment(
+ array(
+ '_tracking_number' => 'TRACK123456',
+ '_tracking_url' => 'https://example.com/track/TRACK123456',
+ '_shipment_provider' => 'fedex',
+ )
+ );
+
+ $rows = $this->sut->export_order_fulfillment_data( array(), $order );
+
+ $this->assertCount( 1, $rows, 'A fulfillment should add one row, not one row per field.' );
+ $this->assertSame( 'Shipment', $rows[0]['name'] );
+ $this->assertStringContainsString( 'TRACK123456', $rows[0]['value'], 'The export should include the tracking number.' );
+ $this->assertStringContainsString( 'https://example.com/track/TRACK123456', $rows[0]['value'], 'The export should include the tracking URL.' );
+ }
+
+ /**
+ * @testdox The order export includes the fulfillment status and the items shipped in it.
+ */
+ public function test_export_includes_status_and_items(): void {
+ list( $order ) = $this->create_order_with_fulfillment(
+ array( '_tracking_number' => 'TRACK123456' )
+ );
+
+ $rows = $this->sut->export_order_fulfillment_data( array(), $order );
+
+ $this->assertCount( 1, $rows );
+ $this->assertStringContainsString( 'Status: Fulfilled', $rows[0]['value'], 'The export should include the fulfillment status.' );
+ $this->assertMatchesRegularExpression( '/Items: .+ x 1/', $rows[0]['value'], 'The export should list the items shipped, with their quantity.' );
+ }
+
+ /**
+ * @testdox The order export includes soft-deleted fulfillments, matching what the eraser clears.
+ */
+ public function test_export_includes_soft_deleted_fulfillment(): void {
+ list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+ array( '_tracking_number' => 'TRACK123456' )
+ );
+
+ $datastore = WC_Data_Store::load( 'order-fulfillment' );
+ $datastore->delete( $fulfillment );
+
+ $values = implode( ' ', wp_list_pluck( $this->sut->export_order_fulfillment_data( array(), $order ), 'value' ) );
+ $this->assertStringContainsString( 'TRACK123456', $values, 'A soft-deleted fulfillment should still appear in the export.' );
+ }
+
+ /**
+ * @testdox The order export leaves the existing rows unchanged for an order without fulfillments.
+ */
+ public function test_export_leaves_order_without_fulfillments_unchanged(): void {
+ $order = OrderHelper::create_order();
+ $existing = array(
+ array(
+ 'name' => 'Order Number',
+ 'value' => (string) $order->get_id(),
+ ),
+ );
+
+ $this->assertSame( $existing, $this->sut->export_order_fulfillment_data( $existing, $order ) );
+ }
+
+ /**
+ * @testdox Several shipments on one order each add one numbered row.
+ */
+ public function test_export_numbers_multiple_shipments(): void {
+ $order = OrderHelper::create_order();
+ $order_items = $order->get_items();
+ $order_item = reset( $order_items );
+ $item_id = $order_item ? $order_item->get_id() : 1;
+
+ foreach ( array( 'FIRST111', 'SECOND22' ) as $tracking ) {
+ FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_id' => (string) $order->get_id(),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ ),
+ array(
+ '_items' => array(
+ array(
+ 'item_id' => $item_id,
+ 'qty' => 1,
+ ),
+ ),
+ '_tracking_number' => $tracking,
+ )
+ );
+ }
+
+ $rows = $this->sut->export_order_fulfillment_data( array(), $order );
+
+ // One row per shipment keeps the accumulated export blob small, which core rewrites
+ // in full on every export page.
+ $this->assertCount( 2, $rows, 'Each shipment should add exactly one row.' );
+ $this->assertSame( array( 'Shipment 1', 'Shipment 2' ), wp_list_pluck( $rows, 'name' ) );
+
+ // read_fulfillments() does not order its query, so assert on the set of values.
+ $values = implode( ' ', wp_list_pluck( $rows, 'value' ) );
+ $this->assertStringContainsString( 'FIRST111', $values );
+ $this->assertStringContainsString( 'SECOND22', $values );
+ }
+
+ /**
+ * @testdox The order export resolves the shipment provider to its display name, not its slug.
+ */
+ public function test_export_uses_provider_display_name(): void {
+ list( $order ) = $this->create_order_with_fulfillment(
+ array(
+ '_tracking_number' => 'TRACK123456',
+ '_shipment_provider' => 'dhl',
+ )
+ );
+
+ // get_provider_display_name() applies this label filter; resolve_provider_name() does not,
+ // so a hit here proves the export resolves the display name rather than the raw slug.
+ add_filter(
+ 'woocommerce_fulfillment_shipment_provider_label',
+ function () {
+ return 'DHL Express';
+ }
+ );
+
+ $values = implode( ' ', wp_list_pluck( $this->sut->export_order_fulfillment_data( array(), $order ), 'value' ) );
+ $this->assertStringContainsString( 'DHL Express', $values, 'The export should use the provider display name.' );
+ }
+
+ /**
+ * @testdox Running the core order exporter attaches the customer's fulfillment rows and excludes other customers.
+ */
+ public function test_core_order_exporter_includes_fulfillment_rows(): void {
+ $this->sut->register();
+
+ list( $order ) = $this->create_order_with_fulfillment(
+ array( '_tracking_number' => 'MINE1111' )
+ );
+
+ $other_order = OrderHelper::create_order();
+ $other_order->set_customer_id( 0 );
+ $other_order->set_billing_email( 'someone-else@example.com' );
+ $other_order->save();
+ FulfillmentsHelper::create_fulfillment(
+ array( 'entity_id' => (string) $other_order->get_id() ),
+ array(
+ '_items' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ '_tracking_number' => 'THEIRS2222',
+ )
+ );
+
+ $export = \WC_Privacy_Exporters::order_data_exporter( $order->get_billing_email(), 1 );
+
+ $values = array();
+ foreach ( $export['data'] as $item ) {
+ $values = array_merge( $values, wp_list_pluck( $item['data'], 'value' ) );
+ }
+ $values = implode( ' ', $values );
+ $this->assertStringContainsString( 'MINE1111', $values, 'The requesting customer tracking number should be exported.' );
+ $this->assertStringNotContainsString( 'THEIRS2222', $values, 'Another customer tracking number must not be exported.' );
+ }
+
+ /**
+ * @testdox The export adds no rows on a store that never created the fulfillment tables.
+ */
+ public function test_export_skips_order_when_tables_were_never_created(): void {
+ list( $order ) = $this->create_order_with_fulfillment(
+ array( '_tracking_number' => 'TRACK123456' )
+ );
+
+ // The exporter is registered for every store with the feature off, so it has to skip
+ // the ones that never created the tables rather than ask for an absent data store.
+ $original = get_option( 'woocommerce_fulfillments_db_tables_created' );
+ delete_option( 'woocommerce_fulfillments_db_tables_created' );
+ try {
+ $rows = $this->sut->export_order_fulfillment_data( array(), $order );
+ } finally {
+ if ( false !== $original ) {
+ update_option( 'woocommerce_fulfillments_db_tables_created', $original );
+ }
+ }
+
+ $this->assertSame( array(), $rows, 'The export should add no fulfillment rows.' );
+ }
+
+ /**
+ * @testdox The eraser stops before redacting on a store that never created the fulfillment tables.
+ */
+ public function test_eraser_skips_redaction_when_tables_were_never_created(): void {
+ list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+ array( '_tracking_number' => 'TRACK123456' )
+ );
+
+ $filter_ran = false;
+ add_filter(
+ 'woocommerce_privacy_remove_fulfillment_personal_data_meta',
+ function ( $meta_keys ) use ( &$filter_ran ) {
+ $filter_ran = true;
+ return $meta_keys;
+ }
+ );
+
+ // The eraser is registered for every store with the feature off, so it has to skip the
+ // ones that never created the tables.
+ $original = get_option( 'woocommerce_fulfillments_db_tables_created' );
+ delete_option( 'woocommerce_fulfillments_db_tables_created' );
+ try {
+ $this->sut->remove_fulfillment_personal_data( $order );
+ } finally {
+ if ( false !== $original ) {
+ update_option( 'woocommerce_fulfillments_db_tables_created', $original );
+ }
+ }
+
+ $this->assertFalse( $filter_ran, 'Redaction should stop before building the query.' );
+ $this->assertSame(
+ 'TRACK123456',
+ $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() )->get_tracking_number(),
+ 'The tracking number should be left untouched.'
+ );
+ }
+
+ /**
+ * @testdox The eraser redacts the tracking number and URL of an active fulfillment.
+ */
+ public function test_eraser_redacts_active_fulfillment(): void {
+ list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+ array(
+ '_tracking_number' => 'TRACK123456',
+ '_tracking_url' => 'https://example.com/track/TRACK123456',
+ '_shipment_provider' => 'fedex',
+ )
+ );
+
+ $this->sut->remove_fulfillment_personal_data( $order );
+
+ $reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() );
+ $this->assertNull( $reloaded->get_tracking_number(), 'The tracking number should be cleared.' );
+ $this->assertNull( $reloaded->get_tracking_url(), 'The tracking URL should be cleared.' );
+ $this->assertSame( 'fedex', $reloaded->get_shipment_provider(), 'The provider is not personal data and should be retained.' );
+ }
+
+ /**
+ * @testdox The eraser redacts a soft-deleted fulfillment, whose meta the data store keeps immutable.
+ */
+ public function test_eraser_redacts_soft_deleted_fulfillment(): void {
+ list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+ array(
+ '_tracking_number' => 'TRACK123456',
+ '_tracking_url' => 'https://example.com/track/TRACK123456',
+ )
+ );
+
+ $fulfillment_id = $fulfillment->get_id();
+ $datastore = WC_Data_Store::load( 'order-fulfillment' );
+ $datastore->delete( $fulfillment );
+
+ $deleted = $this->reload_fulfillment( $order->get_id(), $fulfillment_id );
+ $this->assertNotNull( $deleted->get_date_deleted(), 'The fulfillment should be soft-deleted.' );
+
+ $this->sut->remove_fulfillment_personal_data( $order );
+
+ $reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment_id );
+ $this->assertNull( $reloaded->get_tracking_number(), 'A soft-deleted fulfillment should still be redacted.' );
+ $this->assertNull( $reloaded->get_tracking_url(), 'A soft-deleted fulfillment URL should still be redacted.' );
+ }
+
+ /**
+ * @testdox The eraser honors the meta-key filter.
+ */
+ public function test_eraser_respects_meta_keys_filter(): void {
+ list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+ array(
+ '_tracking_number' => 'TRACK123456',
+ '_tracking_url' => 'https://example.com/track/TRACK123456',
+ )
+ );
+
+ add_filter(
+ 'woocommerce_privacy_remove_fulfillment_personal_data_meta',
+ function () {
+ return array( '_tracking_number' );
+ }
+ );
+
+ $this->sut->remove_fulfillment_personal_data( $order );
+
+ $reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() );
+ $this->assertNull( $reloaded->get_tracking_number(), 'The filtered meta key should be redacted.' );
+ $this->assertSame( 'https://example.com/track/TRACK123456', $reloaded->get_tracking_url(), 'An unfiltered meta key should be retained.' );
+ }
+
+ /**
+ * @testdox Order anonymization through WC_Privacy_Erasers redacts the fulfillment tracking.
+ */
+ public function test_order_anonymization_redacts_fulfillment(): void {
+ $this->sut->register();
+
+ list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+ array(
+ '_tracking_number' => 'TRACK123456',
+ '_tracking_url' => 'https://example.com/track/TRACK123456',
+ )
+ );
+
+ \WC_Privacy_Erasers::remove_order_personal_data( $order );
+
+ $reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() );
+ $this->assertNull( $reloaded->get_tracking_number(), 'Anonymizing the order should redact the fulfillment tracking number.' );
+ }
+
+ /**
+ * @testdox The scheduled retention cleanup redacts fulfillment tracking on anonymized orders.
+ */
+ public function test_retention_cleanup_redacts_fulfillment(): void {
+ $this->sut->register();
+
+ list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+ array(
+ '_tracking_number' => 'TRACK123456',
+ '_tracking_url' => 'https://example.com/track/TRACK123456',
+ )
+ );
+
+ // Make the order eligible for the completed-order retention sweep.
+ $order->set_status( 'completed' );
+ $order->set_date_created( time() - 2 * DAY_IN_SECONDS );
+ $order->save();
+
+ $original = get_option( 'woocommerce_anonymize_completed_orders' );
+ update_option(
+ 'woocommerce_anonymize_completed_orders',
+ array(
+ 'number' => 1,
+ 'unit' => 'days',
+ )
+ );
+
+ \WC_Privacy::anonymize_completed_orders();
+
+ if ( false === $original ) {
+ delete_option( 'woocommerce_anonymize_completed_orders' );
+ } else {
+ update_option( 'woocommerce_anonymize_completed_orders', $original );
+ }
+
+ $reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() );
+ $this->assertNull( $reloaded->get_tracking_number(), 'The retention cleanup should redact the fulfillment tracking number.' );
+ }
+
+ /**
+ * @testdox The eraser only redacts the fulfillments of the given order.
+ */
+ public function test_eraser_only_redacts_the_given_order(): void {
+ list( $order_a, $fulfillment_a ) = $this->create_order_with_fulfillment(
+ array( '_tracking_number' => 'AAAA1111' )
+ );
+ list( $order_b, $fulfillment_b ) = $this->create_order_with_fulfillment(
+ array( '_tracking_number' => 'BBBB2222' )
+ );
+
+ $this->sut->remove_fulfillment_personal_data( $order_a );
+
+ $reloaded_a = $this->reload_fulfillment( $order_a->get_id(), $fulfillment_a->get_id() );
+ $reloaded_b = $this->reload_fulfillment( $order_b->get_id(), $fulfillment_b->get_id() );
+ $this->assertNull( $reloaded_a->get_tracking_number(), 'The erased order should be redacted.' );
+ $this->assertSame( 'BBBB2222', $reloaded_b->get_tracking_number(), 'Another order should be left untouched.' );
+ }
+
+ /**
+ * @testdox The eraser ignores non-scalar meta keys returned by the filter.
+ */
+ public function test_eraser_ignores_non_scalar_filter_keys(): void {
+ list( $order, $fulfillment ) = $this->create_order_with_fulfillment(
+ array( '_tracking_number' => 'TRACK123456' )
+ );
+
+ add_filter(
+ 'woocommerce_privacy_remove_fulfillment_personal_data_meta',
+ function () {
+ return array( '_tracking_number', array( 'unexpected' ) );
+ }
+ );
+
+ $this->sut->remove_fulfillment_personal_data( $order );
+
+ $reloaded = $this->reload_fulfillment( $order->get_id(), $fulfillment->get_id() );
+ $this->assertNull( $reloaded->get_tracking_number(), 'The valid key should be redacted and the non-scalar entry ignored.' );
+ }
+}