Skip to content

Commit bf6d333

Browse files
(SP: 3) [SHOP] audit-driven e2e purchase readiness hardening (events, notifications, consent, returns) (#378)
* (SP:3)[SHOP] add canonical payment/shipping/admin audit tables + dedupe helper with flagged atomic dual-write * (SP: 2)[SHOP] add INTL quote flow (request/offer/accept/decline), payment-init gate, and quote expiry/timeout sweeps * (SP: 3)[SHOP] introduce outbox-driven notifications with projector + worker (phase 3) * (SP: 3)[SHOP] add minimal returns/RMA lifecycle with atomic audit + canonical events (phase 4) * (SP: 3)[SHOP] enforce guest status-token lite-only access and audit token usage (phase 5) * (SP: 3)[SHOP] centralize transition guards and enforce across admin/webhook/worker flows (phase 6) :wq n * (SP:3)[SHOP]: enforce DATABASE_URL_LOCAL preflight + deterministic vitest config * (SP:3)[SHOP]: require canonical events in prod (fail-fast) * (SP:3)[SHOP]: implement notifications transport with retries + DLQ * (SP:3)[SHOP]: persist checkout legal consent artifact * (SP:1)[SHOP] add migration 0025 for consent + events + audit + prices * (SP:1)[SHOP] emit shipping_events for shipment worker transitions * (SP:2)[SHOP] audit admin product mutations (deduped) * (SP:2)[SHOP] make Monobank webhook retryable on transient apply failures * (SP:3) [SHOP] enforce guest status-token scopes across order actions * (SP:1) [SHOP] make product_prices the only write authority * (SP:1) [SHOP] add Playwright e2e smoke suite (local DB only) * (SP:3) [SHOP] explicitly reject exchanges (EXCHANGES_NOT_SUPPORTED) * (SP: 3)[FIX] harden audit + workers/tests; fix transitions/restock + webhook perf * (SP: 3)[FIX] harden local-db test safety and tighten shop reliability guards * (SP: 3)[FIX] fail-closed admin audit, refine shipments-worker outcomes/metrics, tighten quote+tests * (SP: 1)[FIX] harden shipments-worker claiming/leases and make audit+quote/test paths resilient * (SP: 1)[FIX] harden quote request errors/logging and sanitize requestId; document best-effort delete audit
1 parent ffca8ca commit bf6d333

121 files changed

Lines changed: 47530 additions & 478 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ GMAIL_USER=
127127
# --- Shop / Internal
128128
# Optional public/base URL used by shop services/links
129129
SHOP_BASE_URL=
130+
SHOP_PRIVACY_VERSION=privacy-v1
131+
SHOP_TERMS_VERSION=terms-v1
130132

131133
# Required for signed shop status tokens (if status endpoint/token flow is enabled)
132134
SHOP_STATUS_TOKEN_SECRET=
@@ -163,4 +165,4 @@ TRUST_FORWARDED_HEADERS=0
163165
# emergency switch
164166
RATE_LIMIT_DISABLED=0
165167

166-
GROQ_API_KEY=
168+
GROQ_API_KEY=

frontend/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# testing
1414
/coverage
1515
/coverage-quiz
16+
/playwright-report
17+
/test-results
1618

1719
# next.js
1820
/.next/

frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const POLL_BUSY_RETRY_DELAY_MS = 1_000;
7575
const POLL_STOP_ERROR_CODES = new Set([
7676
'STATUS_TOKEN_REQUIRED',
7777
'STATUS_TOKEN_INVALID',
78+
'STATUS_TOKEN_SCOPE_FORBIDDEN',
7879
'UNAUTHORIZED',
7980
'FORBIDDEN',
8081
]);
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import crypto from 'node:crypto';
2+
3+
import { NextRequest, NextResponse } from 'next/server';
4+
5+
import {
6+
AdminApiDisabledError,
7+
AdminForbiddenError,
8+
AdminUnauthorizedError,
9+
requireAdminApi,
10+
} from '@/lib/auth/admin';
11+
import { logError, logWarn } from '@/lib/logging';
12+
import { requireAdminCsrf } from '@/lib/security/admin-csrf';
13+
import { guardBrowserSameOrigin } from '@/lib/security/origin';
14+
import {
15+
InvalidPayloadError,
16+
OrderNotFoundError,
17+
} from '@/lib/services/errors';
18+
import { offerIntlQuote } from '@/lib/services/shop/quotes';
19+
import {
20+
intlQuoteOfferPayloadSchema,
21+
orderIdParamSchema,
22+
} from '@/lib/validation/shop';
23+
24+
function noStoreJson(body: unknown, init?: { status?: number }) {
25+
const res = NextResponse.json(body, { status: init?.status ?? 200 });
26+
res.headers.set('Cache-Control', 'no-store');
27+
return res;
28+
}
29+
30+
function mapQuoteErrorStatus(code: string): number {
31+
if (
32+
code === 'QUOTE_VERSION_CONFLICT' ||
33+
code === 'QUOTE_NOT_APPLICABLE' ||
34+
code === 'QUOTE_ALREADY_ACCEPTED'
35+
) {
36+
return 409;
37+
}
38+
if (code === 'QUOTE_INVALID_EXPIRY') return 422;
39+
return 400;
40+
}
41+
42+
export const runtime = 'nodejs';
43+
44+
export async function POST(
45+
request: NextRequest,
46+
context: { params: Promise<{ id: string }> }
47+
) {
48+
const requestId =
49+
request.headers.get('x-request-id')?.trim() || crypto.randomUUID();
50+
const baseMeta = {
51+
requestId,
52+
route: request.nextUrl.pathname,
53+
method: request.method,
54+
};
55+
let orderIdForLog: string | null = null;
56+
57+
const blocked = guardBrowserSameOrigin(request);
58+
if (blocked) {
59+
blocked.headers.set('Cache-Control', 'no-store');
60+
return blocked;
61+
}
62+
63+
try {
64+
const admin = await requireAdminApi(request);
65+
const csrfRes = requireAdminCsrf(request, 'admin:orders:quote:offer');
66+
if (csrfRes) {
67+
csrfRes.headers.set('Cache-Control', 'no-store');
68+
return csrfRes;
69+
}
70+
71+
const parsedParams = orderIdParamSchema.safeParse(await context.params);
72+
if (!parsedParams.success) {
73+
return noStoreJson(
74+
{ code: 'INVALID_ORDER_ID', message: 'Invalid order id.' },
75+
{ status: 400 }
76+
);
77+
}
78+
orderIdForLog = parsedParams.data.id;
79+
80+
let rawBody: unknown;
81+
try {
82+
rawBody = await request.json();
83+
} catch {
84+
return noStoreJson(
85+
{ code: 'INVALID_PAYLOAD', message: 'Invalid JSON body.' },
86+
{ status: 400 }
87+
);
88+
}
89+
90+
const parsedBody = intlQuoteOfferPayloadSchema.safeParse(rawBody);
91+
if (!parsedBody.success) {
92+
return noStoreJson(
93+
{ code: 'INVALID_PAYLOAD', message: 'Invalid payload.' },
94+
{ status: 400 }
95+
);
96+
}
97+
98+
const payload = parsedBody.data;
99+
const result = await offerIntlQuote({
100+
orderId: orderIdForLog,
101+
requestId,
102+
actorUserId: typeof admin.id === 'string' ? admin.id : null,
103+
version: payload.version,
104+
currency: payload.currency,
105+
shippingQuoteMinor: payload.shippingQuoteMinor,
106+
expiresAt: payload.expiresAt ?? null,
107+
payload: payload.payload,
108+
});
109+
110+
return noStoreJson(
111+
{
112+
success: true,
113+
orderId: result.orderId,
114+
version: result.version,
115+
quoteStatus: result.quoteStatus,
116+
shippingQuoteMinor: result.shippingQuoteMinor,
117+
currency: result.currency,
118+
expiresAt: result.expiresAt.toISOString(),
119+
},
120+
{ status: 200 }
121+
);
122+
} catch (error) {
123+
if (error instanceof AdminApiDisabledError) {
124+
return noStoreJson(
125+
{ code: 'ADMIN_API_DISABLED', message: 'Admin API is disabled.' },
126+
{ status: 403 }
127+
);
128+
}
129+
if (error instanceof AdminUnauthorizedError) {
130+
return noStoreJson(
131+
{ code: error.code, message: 'Unauthorized.' },
132+
{ status: 401 }
133+
);
134+
}
135+
if (error instanceof AdminForbiddenError) {
136+
return noStoreJson({ code: error.code, message: 'Forbidden.' }, { status: 403 });
137+
}
138+
if (error instanceof OrderNotFoundError) {
139+
return noStoreJson({ code: error.code }, { status: 404 });
140+
}
141+
if (error instanceof InvalidPayloadError) {
142+
logWarn('admin_quote_offer_rejected', {
143+
...baseMeta,
144+
orderId: orderIdForLog,
145+
code: error.code,
146+
});
147+
return noStoreJson(
148+
{
149+
code: error.code,
150+
message: error.message,
151+
...(error.details ? { details: error.details } : {}),
152+
},
153+
{ status: mapQuoteErrorStatus(error.code) }
154+
);
155+
}
156+
157+
logError('admin_quote_offer_failed', error, {
158+
...baseMeta,
159+
orderId: orderIdForLog,
160+
code: 'ADMIN_QUOTE_OFFER_FAILED',
161+
});
162+
return noStoreJson(
163+
{ code: 'INTERNAL_ERROR', message: 'Unable to offer quote.' },
164+
{ status: 500 }
165+
);
166+
}
167+
}

frontend/app/api/shop/admin/products/[id]/route.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
getAdminProductByIdWithPrices,
2626
updateProduct,
2727
} from '@/lib/services/products';
28+
import { writeAdminAudit } from '@/lib/services/shop/events/write-admin-audit';
2829

2930
export const runtime = 'nodejs';
3031

@@ -320,7 +321,9 @@ export async function PATCH(
320321
let productIdForLog: string | null = null;
321322

322323
try {
323-
await requireAdminApi(request);
324+
const adminUser = await requireAdminApi(request);
325+
const actorUserId =
326+
adminUser && typeof adminUser.id === 'string' ? adminUser.id : null;
324327

325328
const rawParams = await context.params;
326329
const parsedParams = productIdParamSchema.safeParse(rawParams);
@@ -457,6 +460,51 @@ export async function PATCH(
457460
: undefined,
458461
});
459462

463+
try {
464+
await writeAdminAudit({
465+
actorUserId,
466+
action: 'product_admin_action.update',
467+
targetType: 'product',
468+
targetId: updated.id,
469+
requestId,
470+
payload: {
471+
productId: updated.id,
472+
slug: updated.slug,
473+
title: updated.title,
474+
badge: updated.badge,
475+
isActive: updated.isActive,
476+
isFeatured: updated.isFeatured,
477+
stock: updated.stock,
478+
},
479+
dedupeSeed: {
480+
domain: 'product_admin_action',
481+
action: 'update',
482+
requestId,
483+
productId: updated.id,
484+
slug: updated.slug,
485+
toBadge: updated.badge,
486+
toIsActive: updated.isActive,
487+
toIsFeatured: updated.isFeatured,
488+
toStock: updated.stock,
489+
},
490+
});
491+
} catch (auditError) {
492+
logWarn('admin_product_update_audit_failed', {
493+
...baseMeta,
494+
code: 'AUDIT_WRITE_FAILED',
495+
requestId,
496+
actorUserId,
497+
productId: updated.id,
498+
action: 'product_admin_action.update',
499+
message:
500+
auditError instanceof Error
501+
? auditError.message
502+
: String(auditError),
503+
durationMs: Date.now() - startedAtMs,
504+
});
505+
throw auditError;
506+
}
507+
460508
return noStoreJson({ success: true, product: updated }, { status: 200 });
461509
} catch (error) {
462510
if (error instanceof PriceConfigError) {
@@ -643,7 +691,9 @@ export async function DELETE(
643691
let productIdForLog: string | null = null;
644692

645693
try {
646-
await requireAdminApi(request);
694+
const adminUser = await requireAdminApi(request);
695+
const actorUserId =
696+
adminUser && typeof adminUser.id === 'string' ? adminUser.id : null;
647697

648698
const csrfRes = requireAdminCsrf(request, 'admin:products:delete');
649699
if (csrfRes) {
@@ -704,6 +754,39 @@ export async function DELETE(
704754

705755
await deleteProduct(productIdForLog);
706756

757+
try {
758+
await writeAdminAudit({
759+
actorUserId,
760+
action: 'product_admin_action.delete',
761+
targetType: 'product',
762+
targetId: productIdForLog,
763+
requestId,
764+
payload: {
765+
productId: productIdForLog,
766+
},
767+
dedupeSeed: {
768+
domain: 'product_admin_action',
769+
action: 'delete',
770+
requestId,
771+
productId: productIdForLog,
772+
},
773+
});
774+
} catch (auditError) {
775+
logWarn('admin_product_delete_audit_failed', {
776+
...baseMeta,
777+
code: 'AUDIT_WRITE_FAILED',
778+
requestId,
779+
actorUserId,
780+
productId: productIdForLog,
781+
action: 'product_admin_action.delete',
782+
message:
783+
auditError instanceof Error ? auditError.message : String(auditError),
784+
durationMs: Date.now() - startedAtMs,
785+
});
786+
// Delete is irreversible; keep success response to avoid misleading retries.
787+
// Audit failure is logged and should be monitored/alerted separately.
788+
}
789+
707790
return noStoreJson({ success: true }, { status: 200 });
708791
} catch (error) {
709792
if (error instanceof AdminApiDisabledError) {

frontend/app/api/shop/admin/products/[id]/status/route.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { logError, logWarn } from '@/lib/logging';
1414
import { requireAdminCsrf } from '@/lib/security/admin-csrf';
1515
import { guardBrowserSameOrigin } from '@/lib/security/origin';
1616
import { toggleProductStatus } from '@/lib/services/products';
17+
import { writeAdminAudit } from '@/lib/services/shop/events/write-admin-audit';
1718

1819
export const runtime = 'nodejs';
1920

@@ -55,7 +56,9 @@ export async function PATCH(
5556
let productIdForLog: string | null = null;
5657

5758
try {
58-
await requireAdminApi(request);
59+
const adminUser = await requireAdminApi(request);
60+
const actorUserId =
61+
adminUser && typeof adminUser.id === 'string' ? adminUser.id : null;
5962
const csrfRes = requireAdminCsrf(request, 'admin:products:status');
6063
if (csrfRes) {
6164
logWarn('admin_product_status_csrf_rejected', {
@@ -92,6 +95,41 @@ export async function PATCH(
9295

9396
const updated = await toggleProductStatus(productIdForLog);
9497

98+
try {
99+
await writeAdminAudit({
100+
actorUserId,
101+
action: 'product_admin_action.toggle_status',
102+
targetType: 'product',
103+
targetId: updated.id,
104+
requestId,
105+
payload: {
106+
productId: updated.id,
107+
slug: updated.slug,
108+
isActive: updated.isActive,
109+
},
110+
dedupeSeed: {
111+
domain: 'product_admin_action',
112+
action: 'toggle_status',
113+
requestId,
114+
productId: updated.id,
115+
toIsActive: updated.isActive,
116+
},
117+
});
118+
} catch (auditError) {
119+
logWarn('admin_product_status_audit_failed', {
120+
...baseMeta,
121+
code: 'AUDIT_WRITE_FAILED',
122+
requestId,
123+
actorUserId,
124+
productId: updated.id,
125+
action: 'product_admin_action.toggle_status',
126+
isActive: updated.isActive,
127+
message:
128+
auditError instanceof Error ? auditError.message : String(auditError),
129+
durationMs: Date.now() - startedAtMs,
130+
});
131+
}
132+
95133
return noStoreJson({ success: true, product: updated }, { status: 200 });
96134
} catch (error) {
97135
if (error instanceof AdminApiDisabledError) {

0 commit comments

Comments
 (0)