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
34 changes: 34 additions & 0 deletions docs/officers/EVENTS_SHOP_MEMBERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,40 @@ Officer review steps after the source merge:

No system diagram changes for this source slice because page structure, data movement, permissions, account ownership, and deployment topology are unchanged.

## Public Shop catalog failure privacy — SOURCE ONLY, NOT LIVE

**Status: NOT AVAILABLE YET**

**Purpose:** give any public Shop visitor one plain next step when the product list cannot load, without showing a database, provider, account, or technical error.

**Approver:** communications lead plus platform/security owner.

**Prerequisites:** issue [#254](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/254) must be merged for source review. Calling the sentence live also requires a protected website publication and an exact revision check on `runmprc.com/shop`. This source change does not deploy Firebase, change database permissions, contact an outside provider, use production data, or prove live behavior.

Officer review steps after the source merge:

1. Keep the public Shop failure sentence marked **NOT AVAILABLE YET**.
2. Ask the platform owner for the exact #254 issue, pull request, merged commit, and synthetic frontend test result.
3. Confirm the tests use only a made-up catalog, made-up product, and mocked database result.
4. Confirm a made-up catalog rejection shows `We could not load the shop right now. Please try again later.`
5. Confirm the loading sentence stops and the empty-catalog sentence does not appear for that failure.
6. Confirm no made-up database, provider, account, endpoint, or technical detail appears on the page or in browser console output.
7. Confirm a hostile rejected value is not inspected.
8. Confirm a genuinely empty made-up catalog and a successful made-up product still use their existing displays.
9. Record website publication, `runmprc.com/shop`, Firebase, provider, production-data, and live-behavior evidence as separate results.

**Expected result:** the reviewed source uses one fixed retry-later sentence for a catalog rejection. It does not inspect, display, or log the rejected value. The failure is announced as an alert, while successful and genuinely empty catalogs remain unchanged.

**Stop conditions:** any real member, customer, order, or product data; a request for a database or provider error, account detail, private endpoint, or screenshot containing private values; a production Firebase or provider change; a raw detail on the page or in the console; or a claim that source, tests, merge, or a green workflow proves the sentence is live.

**Success proof:** for source completion, record the exact #254 issue, reviewed pull request, merged commit, intended old-source failures, green synthetic tests, relevant full checks, and independent privacy review. For live availability, separately record the approved website publication, published revision, and a dated `runmprc.com/shop` check that uses no private or production data. Record Firebase deployment, database-permission changes, provider configuration, and production-data actions as **not performed** for this frontend-only change.

**Undo:** before publication, use one reviewed frontend revert or safe roll-forward. After publication, use the same protected website release path and verify the replacement revision on `runmprc.com/shop`. Do not undo by changing a product, order, member account, database record, permission, or provider setting.

**Escalation:** communications lead plus platform/security owner. Add the privacy owner and use the private incident path if any database, provider, account, endpoint, or technical detail appeared. Do not copy the detail into an issue, message, screenshot, email, or AI tool.

No system diagram changes for this source slice because page structure, data movement, permissions, account ownership, and deployment topology are unchanged.

## Refund amount and returned-result guards — SOURCE ONLY, NOT LIVE

**Purpose:** make an invalid partial amount stop, and record a refund complete only when Stripe returns a matching final success.
Expand Down
120 changes: 119 additions & 1 deletion src/App.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { resolvePath } from 'react-router-dom';
import { useServiceLocator } from './services/ServiceLocatorContext';
import { listActiveProducts } from './services/shop/shopService';
import App from './App';

jest.mock('./services/ServiceLocatorProvider', () => function TestServiceLocatorProvider({
Expand All @@ -12,9 +14,17 @@ jest.mock('./services/ServiceLocatorProvider', () => function TestServiceLocator
});

jest.mock('./services/ServiceLocatorContext', () => ({
useServiceLocator: () => ({ services: null, isReady: false }),
useServiceLocator: jest.fn(() => ({ services: null, isReady: false })),
}));

jest.mock('./services/shop/shopService', () => {
const actual = jest.requireActual('./services/shop/shopService');
return {
...actual,
listActiveProducts: jest.fn(),
};
});

jest.mock('./services/hooks/useAuth', () => ({
useAuth: () => ({
user: null,
Expand All @@ -28,6 +38,12 @@ jest.mock('./services/hooks/useAuth', () => ({
}),
}));

beforeEach(() => {
useServiceLocator.mockReset();
useServiceLocator.mockReturnValue({ services: null, isReady: false });
listActiveProducts.mockReset();
});

afterEach(() => {
window.history.replaceState({}, '', '/');
});
Expand Down Expand Up @@ -109,3 +125,105 @@ test('renders the Auth action route and removes its query and fragment before us
expect(window.location.search).toBe('');
expect(window.location.hash).toBe('');
});

const SHOP_LOAD_FAILURE = 'We could not load the shop right now. Please try again later.';
const firestore = { name: 'synthetic-firestore' };

function renderPublicShop() {
window.history.pushState({}, '', '/shop');
return render(<App />);
}

describe('public Shop catalog failure boundary', () => {
beforeEach(() => {
useServiceLocator.mockReturnValue({
services: { firebaseResources: { firestore } },
isReady: true,
});
listActiveProducts.mockResolvedValue([]);
});

afterEach(() => {
jest.restoreAllMocks();
});

test('replaces rejected catalog details with one fixed accessible result', async () => {
const consoleSpies = ['debug', 'error', 'info', 'log', 'warn']
.map((method) => jest.spyOn(console, method).mockImplementation(() => undefined));
listActiveProducts.mockRejectedValueOnce(Object.assign(
new Error('shop-provider-private-canary member@example.test'),
{
code: 'firestore/shop-provider-private-canary',
endpoint: 'https://provider.example.test/?token=secret-canary',
},
));

renderPublicShop();

const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe(SHOP_LOAD_FAILURE);
expect(alert).toHaveAttribute('aria-live', 'assertive');
expect(alert).toHaveAttribute('aria-atomic', 'true');
expect(document.body).not.toHaveTextContent(
/shop-provider-private-canary|member@example\.test|provider\.example|secret-canary/i,
);
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(screen.queryByText('No items available right now. Check back soon.'))
.not.toBeInTheDocument();
expect(listActiveProducts).toHaveBeenCalledWith(firestore);
expect(listActiveProducts).toHaveBeenCalledTimes(1);
consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled());
});

test('does not inspect or log a hostile catalog rejection', async () => {
const consoleSpies = ['debug', 'error', 'info', 'log', 'warn']
.map((method) => jest.spyOn(console, method).mockImplementation(() => undefined));
const messageGetter = jest.fn(() => {
throw new Error('shop-message-getter-canary');
});
listActiveProducts.mockRejectedValueOnce(
Object.defineProperty({}, 'message', {
configurable: true,
get: messageGetter,
}),
);

renderPublicShop();

expect((await screen.findByRole('alert')).textContent).toBe(SHOP_LOAD_FAILURE);
expect(messageGetter).not.toHaveBeenCalled();
expect(document.body).not.toHaveTextContent('shop-message-getter-canary');
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled());
});

test('preserves the existing empty catalog result', async () => {
renderPublicShop();

expect(await screen.findByText('No items available right now. Check back soon.'))
.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(listActiveProducts).toHaveBeenCalledWith(firestore);
expect(listActiveProducts).toHaveBeenCalledTimes(1);
});

test('preserves the existing successful product projection', async () => {
listActiveProducts.mockResolvedValueOnce([{
id: 'synthetic-product',
slug: 'synthetic-club-shirt',
title: 'Synthetic Club Shirt',
imageUrl: '',
priceCents: 2500,
status: 'active',
}]);

renderPublicShop();

expect(await screen.findByRole('link', { name: /Synthetic Club Shirt/ }))
.toHaveAttribute('href', '/shop/synthetic-club-shirt');
expect(screen.getByText('$25.00')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(listActiveProducts).toHaveBeenCalledWith(firestore);
expect(listActiveProducts).toHaveBeenCalledTimes(1);
});
});
4 changes: 2 additions & 2 deletions src/pages/shop/Shop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function Shop() {
if (!isReady || !services) return;
listActiveProducts(services.firebaseResources.firestore)
.then((ps) => { setProducts(ps); setLoading(false); })
.catch((err) => { setError(err.message); setLoading(false); });
.catch(() => { setError('We could not load the shop right now. Please try again later.'); setLoading(false); });
}, [services, isReady]);

return (
Expand All @@ -29,7 +29,7 @@ function Shop() {
<div className="container mx-auto p-4 max-w-5xl">
<h1 className="text-2xl font-bold mb-4">MPRC Shop</h1>
{loading && <p className="text-gray-500">Loading...</p>}
{error && <p className="text-red-500">{error}</p>}
{error && <p role="alert" aria-live="assertive" aria-atomic="true" className="text-red-500">{error}</p>}
{!loading && !error && products.length === 0 && (
<p className="text-gray-600">No items available right now. Check back soon.</p>
)}
Expand Down