feat: server-authoritative checkout, no client-computed amount
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
F14-F16 of the frontend backlog. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2. The highest-priority change in Phase 1: `POST /cart` sent `amount` computed client-side (this.convertTotal(this.totalWithDelivery())) and the backend was asked to trust it. Replaced with two calls: 1. POST /api/v2/storefront/checkout - offer ids + qty only. Returns checkoutSessionId and the server-computed total. 2. POST /api/v2/storefront/payments/intents - references checkoutSessionId only. Same response shape as before (qrId/qrUrl/bankUrl/qrTTL via the existing resolvePaymentQrId/resolvePaymentLink/resolveBankPaymentUrl helpers) - this replaces how the charged amount is determined, not the QR/card provider polling flow, which Phase 1 does not redesign. merchantReference (PARTNER-PROVISIONING-API-CONTRACT.md's RoutingContext field) is sent on the payment intent, generated the same way the old orderId was - our own correlation id, now with a name that matches what it is. api.service.ts: CheckoutSessionRequest/Response and PaymentIntentRequest types added, old CartPaymentRequest/createCartPayment left in place (Phase 7 reconciliation and any other caller may still reference the shape) but no longer called from checkout. offerId uses item.itemID: this codebase has no distinct Offer entity yet (Phase 3, Product/Offer split, not shipped in this model) - itemID is the same catalog identifier every other endpoint already keys off. Flagged in a code comment for whoever ships Phase 3 to revisit. Dead code removed as a consequence, not a separate pass: buildPaymentItems, getPaymentUserId, getPaymentDescription (no other caller once the old payload was gone), the ConfigService/TenantResolverService injects that existed only for getPaymentDescription, and the now-orphaned cart.paymentDescriptionFallback i18n key in all three locales. Verification: cart.component.ts has no unit spec (no src/app/pages/cart/ *.spec.ts exists) - this session's E2E suite is the only coverage the checkout request shape has. Added checkout-request-shape.spec.ts, scoped narrowly to the request/response contract rather than a full add-to-cart UI journey: seeds cart state directly into localStorage, fakes the customer session via cookie + intercepted session-check, intercepts both new endpoints and asserts on the captured request bodies. Confirms concretely: no `amount` or `price` field ever leaves the client, offers carry the right offerId/qty, and the payment intent correctly threads checkoutSessionId through. Verified: 5/5 E2E green, 115/115 unit tests green, arch:check clean, production build succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
186
e2e/checkout-request-shape.spec.ts
Normal file
186
e2e/checkout-request-shape.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Page, Route, expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Guards the specific contract this rewrite exists to enforce
|
||||
* (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2): the amount actually charged
|
||||
* must be computed server-side, never sent by the client. Before this
|
||||
* rewrite, POST /cart carried a client-computed `amount` the backend was
|
||||
* asked to trust.
|
||||
*
|
||||
* cart.component.ts has no unit spec (no src/app/pages/cart/*.spec.ts
|
||||
* exists), so this E2E test is the only coverage the checkout request shape
|
||||
* has. Scoped narrowly on purpose: cart state is seeded directly into
|
||||
* localStorage and the customer session is faked via cookie + intercepted
|
||||
* session-check, rather than driving a full add-to-cart-then-login UI
|
||||
* journey - that journey is real product surface worth its own test, but
|
||||
* would make this test about navigation, not about what it exists to prove.
|
||||
*/
|
||||
|
||||
const FAKE_SESSION_ID = 'e2e-fake-session';
|
||||
const FAKE_ITEM = {
|
||||
categoryID: 1,
|
||||
itemID: 4242,
|
||||
name: 'E2E Test Item',
|
||||
photos: null,
|
||||
description: '',
|
||||
currency: 'RUB',
|
||||
price: 1000,
|
||||
discount: 0,
|
||||
rating: 0,
|
||||
callbacks: null,
|
||||
questions: null,
|
||||
quantity: 2,
|
||||
};
|
||||
|
||||
test.describe('checkout request shape', () => {
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await seedCart(page);
|
||||
await fakeCustomerSession(page, context);
|
||||
await mockFxQuoteEndpoint(page);
|
||||
});
|
||||
|
||||
test('checkout session request carries offers and qty, never amount or price', async ({ page }) => {
|
||||
const checkoutRequest = interceptCheckoutSession(page);
|
||||
|
||||
await page.goto('/cart');
|
||||
await acceptTermsAndCheckout(page);
|
||||
|
||||
const body = await checkoutRequest;
|
||||
|
||||
expect(body, 'must never send a client-computed amount').not.toHaveProperty('amount');
|
||||
expect(body, 'must never send a client-computed price').not.toHaveProperty('price');
|
||||
expect(Array.isArray(body.offers), 'must send an offers array').toBe(true);
|
||||
expect(body.offers[0]).toMatchObject({ offerId: String(FAKE_ITEM.itemID), qty: FAKE_ITEM.quantity });
|
||||
});
|
||||
|
||||
test('payment intent request references the checkout session id, not a raw amount', async ({ page }) => {
|
||||
interceptCheckoutSession(page); // must resolve for the intent call to fire at all
|
||||
const intentRequest = interceptPaymentIntent(page);
|
||||
|
||||
await page.goto('/cart');
|
||||
await acceptTermsAndCheckout(page);
|
||||
|
||||
const body = await intentRequest;
|
||||
|
||||
expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture');
|
||||
expect(body).not.toHaveProperty('amount');
|
||||
expect(typeof body.merchantReference).toBe('string');
|
||||
expect(body.merchantReference.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function seedCart(page: Page): Promise<void> {
|
||||
await page.addInitScript(item => {
|
||||
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
|
||||
}, FAKE_ITEM);
|
||||
}
|
||||
|
||||
async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise<void> {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'webSessionID',
|
||||
value: FAKE_SESSION_ID,
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
|
||||
// Matches TelegramSessionApiService.normalizeWebSession's expected shape.
|
||||
await page.route('**/users/sessions/**', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
sessionId: FAKE_SESSION_ID,
|
||||
status: 'active',
|
||||
username: 'e2e_user',
|
||||
userId: 1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockFxQuoteEndpoint(page: Page): Promise<void> {
|
||||
await page.route('**/api/v2/pricing/fx-quote**', route => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
quoteId: 'fxq_e2e',
|
||||
base: 'RUB',
|
||||
quote: 'RUB',
|
||||
rate: 1,
|
||||
source: 'e2e-fixture',
|
||||
observedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function interceptCheckoutSession(page: Page): Promise<Record<string, unknown>> {
|
||||
return new Promise(resolve => {
|
||||
page.route('**/api/v2/storefront/checkout', (route: Route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
resolve(body);
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
checkoutSessionId: 'chk_e2e_fixture',
|
||||
lines: [{
|
||||
offerId: String(FAKE_ITEM.itemID),
|
||||
qty: FAKE_ITEM.quantity,
|
||||
unitPrice: { amountMinor: FAKE_ITEM.price * 100, currency: 'RUB' },
|
||||
lineTotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||
priceSnapshotId: 'snap_e2e',
|
||||
}],
|
||||
subtotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||
discount: { amountMinor: 0, currency: 'RUB' },
|
||||
delivery: { amountMinor: 0, currency: 'RUB' },
|
||||
total: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
|
||||
fxQuoteId: 'fxq_e2e',
|
||||
expiresAt: new Date(Date.now() + 300_000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function interceptPaymentIntent(page: Page): Promise<Record<string, unknown>> {
|
||||
return new Promise(resolve => {
|
||||
page.route('**/api/v2/storefront/payments/intents', (route: Route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
resolve(body);
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
qrId: 'qr_e2e_fixture',
|
||||
nspkurl: 'https://example.com/pay/e2e',
|
||||
qrTTL: 5,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function acceptTermsAndCheckout(page: Page): Promise<void> {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// #terms-checkbox is a custom-styled input (zero-size native element, a
|
||||
// <label> renders the visible box) - .check() refuses on geometry even
|
||||
// with force:true, so toggle it via its label the way a real user would.
|
||||
const termsCheckbox = page.locator('#terms-checkbox');
|
||||
if (await termsCheckbox.count() > 0) {
|
||||
const label = page.locator('label[for="terms-checkbox"]');
|
||||
if (await label.count() > 0) {
|
||||
await label.click();
|
||||
} else {
|
||||
await termsCheckbox.dispatchEvent('click');
|
||||
}
|
||||
}
|
||||
|
||||
const qrButton = page.getByRole('button', { name: /qr/i }).first();
|
||||
await qrButton.click();
|
||||
}
|
||||
Reference in New Issue
Block a user