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
17 changes: 17 additions & 0 deletions src/StorefrontCart.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,23 @@ public function summary(Request $request): ?array
return $this->summaryOf($port->contents($meta['token']));
}

/**
* GET `/ext/shop/cart/summary` — the visitor's own cart **count** as JSON, so a
* theme can fill a header badge on a page-cached CONTENT page WITHOUT baking a
* per-visitor count into the shared cache. Read-only (never mints); the answer
* is a pure function of the caller's own opaque HttpOnly `nb_cart` cookie (no
* id parameter → no IDOR; no cookie → 0). Returns ONLY `{count}` — no lines, no
* token, no PII. `no-store` + being JSON keep it out of the page cache (core
* caches only 200 text/html), so it can never leak across visitors.
*/
public function summaryResponse(Request $request): Response
{
$summary = $this->summary($request);
$count = $summary === null ? 0 : $summary['count'];
return Response::json(['count' => $count])
->withHeader('Cache-Control', 'no-store, private');
}

// --- helpers ---------------------------------------------------------

/**
Expand Down
4 changes: 4 additions & 0 deletions src/StorefrontPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ public function register(PluginContext $context): void

// The cart + checkout mutations — public POST actions (ADR 0017),
// CSRF-guarded, that redirect (POST-redirect-GET) to a private page.
// The visitor's own cart count as JSON (no-store, count-only) — lets a theme
// fill a header badge on a page-cached content page without baking a
// per-visitor count into the shared cache (ADR 0017).
$context->routes()->get('shop', '/cart/summary', static fn (Request $r, array $p): Response => $cart->summaryResponse($r));
$context->routes()->post('shop', '/cart/add', static fn (Request $r, array $p): Response => $cart->add($r));
$context->routes()->post('shop', '/cart/update', static fn (Request $r, array $p): Response => $cart->update($r));
$context->routes()->post('shop', '/checkout', static fn (Request $r, array $p): Response => $cart->checkout($r));
Expand Down
28 changes: 28 additions & 0 deletions tests/StorefrontCartTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,34 @@ public function categories(): array
self::assertSame('1.80', $order['lines'][0]['line_total']);
}

public function test_the_cart_summary_endpoint_is_count_only_json_and_never_cached(): void
{
$token = $this->port->seed('sec');
$this->port->stubContents = ['lines' => [
['sku_code' => 'apple', 'qty' => 2, 'name' => 'Apple', 'unit' => null, 'unit_price' => '1.00', 'line_total' => '2.00', 'availability' => 'in_stock'],
['sku_code' => 'milk', 'qty' => 3, 'name' => 'Milk', 'unit' => null, 'unit_price' => '1.00', 'line_total' => '3.00', 'availability' => 'in_stock'],
], 'total' => '5.00', 'count' => 2];

$res = $this->cart->summaryResponse($this->request('GET', [], $token));

self::assertSame(200, $res->status);
self::assertStringContainsString('application/json', (string) $res->header('Content-Type'));
self::assertStringContainsString('no-store', (string) $res->header('Cache-Control'), 'never entered the page cache');
self::assertStringContainsString('"count":5', $res->body, 'the count is Σ line qty');
// Count ONLY — no lines, SKUs, total, or any cart contents leak.
self::assertStringNotContainsString('apple', $res->body);
self::assertStringNotContainsString('total', $res->body);
self::assertStringNotContainsString('line', $res->body);
self::assertSame([], $this->port->minted, 'reading the summary never mints a cart');
}

public function test_the_cart_summary_endpoint_is_zero_without_a_cart(): void
{
$res = $this->cart->summaryResponse($this->request('GET'));
self::assertStringContainsString('"count":0', $res->body);
self::assertStringContainsString('no-store', (string) $res->header('Cache-Control'));
}

public function test_the_confirmation_falls_back_to_ref_only_without_commerce(): void
{
// No order-read port wired → order is null, the page still renders.
Expand Down
Loading