diff --git a/src/CommercePlugin.php b/src/CommercePlugin.php index 239281f..427301c 100644 --- a/src/CommercePlugin.php +++ b/src/CommercePlugin.php @@ -50,6 +50,10 @@ public function register(PluginContext $context): void $cart = new Cart($storage, $catalog, $orders); $context->services()->provide(CartPort::class, new CartAdapter($cart)); + // A public-safe order read (ADR 0026), so a storefront can render an + // itemised confirmation without touching Commerce's tables or its PII. + $context->services()->provide(OrderReadPort::class, new OrderReadAdapter($orders)); + // Sweep abandoned carts (a client row per anonymous visitor) on the // maintenance schedule, so the table can't grow without bound. $context->maintenance()->register('commerce-cart-gc', static fn (): int => $cart->gc(date('Y-m-d H:i:s'))); diff --git a/src/OrderReadAdapter.php b/src/OrderReadAdapter.php new file mode 100644 index 0000000..124a7e4 --- /dev/null +++ b/src/OrderReadAdapter.php @@ -0,0 +1,52 @@ +orders->get($ref); + if ($order === null) { + return null; + } + + $lines = []; + foreach (is_array($order['lines'] ?? null) ? $order['lines'] : [] as $line) { + if (!is_array($line)) { + continue; + } + $qty = (int) ($line['qty'] ?? 0); + $unitPrice = (string) ($line['unit_price'] ?? '0.00'); + $lines[] = [ + 'sku_code' => (string) ($line['sku_code'] ?? ''), + 'qty' => $qty, + 'unit_price' => $unitPrice, + // Same decimal discipline the cart uses (number_format, 2dp). + 'line_total' => number_format((float) $unitPrice * $qty, 2, '.', ''), + ]; + } + + // Explicit allow-list — NEVER spread $order (drops customer_email, id, …). + return [ + 'reference' => (string) ($order['reference'] ?? ''), + 'status' => (string) ($order['status'] ?? ''), + 'total' => (string) ($order['total'] ?? '0.00'), + 'placed_at' => isset($order['placed_at']) ? (string) $order['placed_at'] : null, + 'lines' => $lines, + ]; + } +} diff --git a/src/OrderReadPort.php b/src/OrderReadPort.php new file mode 100644 index 0000000..2a83230 --- /dev/null +++ b/src/OrderReadPort.php @@ -0,0 +1,36 @@ +services()->get(OrderReadPort::class)` + * — `null` when Commerce is absent — and never touches Commerce's tables. + * + * **Public-safe by construction.** It returns ONLY an allow-listed projection of an + * order: its reference, coarse status, total, timestamp, and line items (sku, qty, + * unit price, line total). It deliberately withholds the customer email, internal + * ids, and stock location — nothing the confirmation page shouldn't show. The + * caller is responsible for authorising *which* order the visitor may read (the + * storefront gates `/order/{ref}` on the one-time order cookie, ADR 0026); this port + * only shapes a safe view, it grants no access decision. + */ +interface OrderReadPort +{ + /** + * One order by reference, projected to public-safe fields, or null when absent. + * + * @return array{ + * reference:string, + * status:string, + * total:string, + * placed_at:?string, + * lines:list + * }|null + */ + public function get(string $ref): ?array; +} diff --git a/tests/OrderReadAdapterTest.php b/tests/OrderReadAdapterTest.php new file mode 100644 index 0000000..84bbd42 --- /dev/null +++ b/tests/OrderReadAdapterTest.php @@ -0,0 +1,98 @@ +db = new Connection([ + 'host' => getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach (CommerceSchema::all() as $sql) { + $this->db->execute($sql); + } + $this->db->execute('TRUNCATE ' . CommerceSchema::ORDER); + $this->db->execute('TRUNCATE ' . CommerceSchema::LINE); + + $storage = fn (): PluginStorage => new PluginStorage($this->db); + $this->adapter = new OrderReadAdapter(new OrderBook($storage, static fn () => null, null)); + } + + private function seedOrder(): void + { + $this->db->execute( + 'INSERT INTO ' . CommerceSchema::ORDER . ' (reference, status, customer_email, currency, total, placed_at, updated_at) + VALUES (:ref, :st, :em, :cur, :tot, :placed, :updated)', + ['ref' => 'ORD-1', 'st' => 'placed', 'em' => 'secret@example.test', 'cur' => 'USD', 'tot' => '5.40', 'placed' => '2026-01-01 09:00:00', 'updated' => '2026-01-01 09:00:00'], + ); + $id = (int) $this->db->selectOne('SELECT id FROM ' . CommerceSchema::ORDER . ' WHERE reference = :r', ['r' => 'ORD-1'])['id']; + foreach ([['avocado', '2', '0.90'], ['bananas', '3', '1.20']] as [$sku, $qty, $price]) { + $this->db->execute( + 'INSERT INTO ' . CommerceSchema::LINE . ' (order_id, sku_code, location, qty, unit_price) VALUES (:oid, :sku, :loc, :qty, :price)', + ['oid' => $id, 'sku' => $sku, 'loc' => 'main', 'qty' => $qty, 'price' => $price], + ); + } + } + + public function test_projects_only_public_safe_fields(): void + { + $this->seedOrder(); + $order = $this->adapter->get('ORD-1'); + + self::assertNotNull($order); + self::assertSame('ORD-1', $order['reference']); + self::assertSame('placed', $order['status']); + self::assertSame('5.40', $order['total']); + self::assertSame('2026-01-01 09:00:00', $order['placed_at']); + self::assertCount(2, $order['lines']); + self::assertSame(['sku_code' => 'avocado', 'qty' => 2, 'unit_price' => '0.90', 'line_total' => '1.80'], $order['lines'][0]); + self::assertSame(['sku_code' => 'bananas', 'qty' => 3, 'unit_price' => '1.20', 'line_total' => '3.60'], $order['lines'][1]); + } + + public function test_never_leaks_pii_or_internal_ids(): void + { + $this->seedOrder(); + $order = $this->adapter->get('ORD-1'); + self::assertNotNull($order); + + // The whole projected payload — the confirmation page renders from this. + $flat = (string) json_encode($order); + self::assertStringNotContainsString('secret@example.test', $flat, 'the customer email must never reach the public page'); + + self::assertArrayNotHasKey('customer_email', $order); + self::assertArrayNotHasKey('id', $order); + self::assertArrayNotHasKey('currency', $order); + self::assertArrayNotHasKey('updated_at', $order); + self::assertArrayNotHasKey('id', $order['lines'][0]); + self::assertArrayNotHasKey('location', $order['lines'][0]); + self::assertArrayNotHasKey('order_id', $order['lines'][0]); + } + + public function test_a_missing_order_is_null(): void + { + self::assertNull($this->adapter->get('NOPE')); + } +}