Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/CommercePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')));
Expand Down
52 changes: 52 additions & 0 deletions src/OrderReadAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Commerce;

/**
* Commerce's implementation of {@see OrderReadPort} — projects {@see OrderBook}'s
* full order row (a `SELECT *` that includes the customer email and internal ids)
* down to the public-safe allow-list. It builds a NEW array field by field and
* never spreads the raw row, so a future `commerce_order` column can't silently
* leak onto a public confirmation page.
*/
final class OrderReadAdapter implements OrderReadPort
{
public function __construct(private OrderBook $orders)
{
}

public function get(string $ref): ?array
{
$order = $this->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,
];
}
}
36 changes: 36 additions & 0 deletions src/OrderReadPort.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Commerce;

/**
* The read contract Commerce publishes so a storefront can render a public **order
* confirmation** — the read counterpart to {@see CartPort} (ADR 0019 service ports;
* ADR 0026 public checkout). A presentation plugin (the Storefront) depends on this
* **interface**, obtains the live one via `$ctx->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<array{sku_code:string,qty:int,unit_price:string,line_total:string}>
* }|null
*/
public function get(string $ref): ?array;
}
98 changes: 98 additions & 0 deletions tests/OrderReadAdapterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Commerce\Tests;

use Nimbus\Database\Connection;
use Nimbus\Plugin\PluginStorage;
use NimbusCMS\Commerce\OrderBook;
use NimbusCMS\Commerce\OrderReadAdapter;
use NimbusCMS\Commerce\Schema as CommerceSchema;
use PHPUnit\Framework\TestCase;

/**
* The public order-read projection (ADR 0026 / OrderReadPort). The one control
* that matters: the adapter emits ONLY an allow-listed, public-safe view of an
* order — it must never leak the customer email or internal ids/location onto the
* confirmation page, even though OrderBook::get returns the whole row.
*/
final class OrderReadAdapterTest extends TestCase
{
private Connection $db;
private OrderReadAdapter $adapter;

protected function setUp(): void
{
$this->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'));
}
}
Loading