Skip to content

Commit 7066d39

Browse files
author
Nguyen Son
committed
fix: harden portfolio storefront QA and flash sale polish
1 parent d47b57f commit 7066d39

67 files changed

Lines changed: 1048 additions & 360 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.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ playwright-report/
6969
frontend/playwright-report/
7070
frontend/test-results/
7171
frontend/artifacts/
72+
docs/portfolio/screenshots/
73+
frontend/docs/portfolio/qa-current/
7274

7375
# Cursor
7476
.cursor/

backend/src/main/java/com/bookstore/config/CatalogDataSeeder.java

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
import org.slf4j.Logger;
1010

1111
import java.math.BigDecimal;
12+
import java.text.Normalizer;
1213
import java.util.ArrayList;
1314
import java.util.HashSet;
1415
import java.util.LinkedHashSet;
1516
import java.util.List;
1617
import java.util.Locale;
18+
import java.util.Map;
1719
import java.util.Random;
1820
import java.util.Set;
1921

@@ -29,6 +31,50 @@ public final class CatalogDataSeeder {
2931
"Tái bản chọn lọc",
3032
"Bản sưu tầm"
3133
};
34+
private static final Map<String, String> PRIORITY_GENERATED_COVERS = Map.ofEntries(
35+
Map.entry("nghe-thuat/mau-sac-va-cam-xuc-an-ban-dac-biet", "/images/books/generated/nghe-thuat-mau-sac-va-cam-xuc-an-ban-dac-biet.png"),
36+
Map.entry("nghe-thuat/anh-dep-quanh-ta-an-ban-dac-biet", "/images/books/generated/nghe-thuat-anh-dep-quanh-ta-an-ban-dac-biet.png"),
37+
Map.entry("nghe-thuat/nhap-mon-hoi-hoa-an-ban-dac-biet", "/images/books/generated/nghe-thuat-nhap-mon-hoi-hoa-an-ban-dac-biet.png"),
38+
Map.entry("nghe-thuat/so-tay-sang-tao", "/images/books/generated/nghe-thuat-so-tay-sang-tao.png"),
39+
Map.entry("nghe-thuat/chat-lieu-va-anh-sang", "/images/books/generated/nghe-thuat-chat-lieu-va-anh-sang.png"),
40+
Map.entry("nghe-thuat/bo-cuc-trong-thiet-ke", "/images/books/generated/nghe-thuat-bo-cuc-trong-thiet-ke.png"),
41+
Map.entry("nghe-thuat/cau-chuyen-my-thuat", "/images/books/generated/nghe-thuat-cau-chuyen-my-thuat.png"),
42+
Map.entry("nghe-thuat/nhiep-anh-duong-pho", "/images/books/generated/nghe-thuat-nhiep-anh-duong-pho.png"),
43+
Map.entry("nghe-thuat/thiet-ke-tu-co-ban", "/images/books/generated/nghe-thuat-thiet-ke-tu-co-ban.png"),
44+
Map.entry("nghe-thuat/ky-hoa-moi-ngay", "/images/books/generated/nghe-thuat-ky-hoa-moi-ngay.png"),
45+
Map.entry("nghe-thuat/mau-sac-va-cam-xuc", "/images/books/generated/nghe-thuat-mau-sac-va-cam-xuc.png"),
46+
Map.entry("nghe-thuat/anh-dep-quanh-ta", "/images/books/generated/nghe-thuat-anh-dep-quanh-ta.png"),
47+
Map.entry("nghe-thuat/nhap-mon-hoi-hoa", "/images/books/generated/nghe-thuat-nhap-mon-hoi-hoa.png"),
48+
Map.entry("am-thuc/cam-nang-lam-banh-an-ban-dac-biet", "/images/books/generated/am-thuc-cam-nang-lam-banh-an-ban-dac-biet.png"),
49+
Map.entry("am-thuc/mon-viet-cuoi-tuan-an-ban-dac-biet", "/images/books/generated/am-thuc-mon-viet-cuoi-tuan-an-ban-dac-biet.png"),
50+
Map.entry("am-thuc/bep-nha-an-yen-an-ban-dac-biet", "/images/books/generated/am-thuc-bep-nha-an-yen-an-ban-dac-biet.png"),
51+
Map.entry("am-thuc/nghe-thuat-pha-che", "/images/books/generated/am-thuc-nghe-thuat-pha-che.png"),
52+
Map.entry("am-thuc/thuc-don-30-ngay", "/images/books/generated/am-thuc-thuc-don-30-ngay.png"),
53+
Map.entry("am-thuc/mon-ngon-dai-khach", "/images/books/generated/am-thuc-mon-ngon-dai-khach.png"),
54+
Map.entry("am-thuc/huong-vi-mien-trung", "/images/books/generated/am-thuc-huong-vi-mien-trung.png"),
55+
Map.entry("am-thuc/bep-chay-moi-ngay", "/images/books/generated/am-thuc-bep-chay-moi-ngay.png"),
56+
Map.entry("am-thuc/bua-com-gia-dinh", "/images/books/generated/am-thuc-bua-com-gia-dinh.png"),
57+
Map.entry("sach-giao-khoa/toan-lop-1", "/images/books/generated/sach-giao-khoa-toan-lop-1.png"),
58+
Map.entry("phat-trien-ban-than/doc-vi-ban-than", "/images/books/generated/phat-trien-ban-than-doc-vi-ban-than.png"),
59+
Map.entry("lich-su/lich-su-viet-nam", "/images/books/generated/lich-su-lich-su-viet-nam.png"),
60+
Map.entry("tieu-thuyet/dac-nhan-tam", "/images/books/generated/tieu-thuyet-dac-nhan-tam.png"),
61+
Map.entry("sach-thieu-nhi/co-be-ban-diem", "/images/books/generated/sach-thieu-nhi-co-be-ban-diem.png"),
62+
Map.entry("tho/dac-nhan-tam", "/images/books/generated/tho-dac-nhan-tam.png"),
63+
Map.entry("marketing/nghi-lon", "/images/books/generated/marketing-nghi-lon.png"),
64+
Map.entry("lanh-dao/nghi-lon", "/images/books/generated/lanh-dao-nghi-lon.png"),
65+
Map.entry("cong-nghe/co-the-cua-ban", "/images/books/generated/cong-nghe-co-the-cua-ban.png"),
66+
Map.entry("sach-thieu-nhi/doraemon-tap-1-50", "/images/books/generated/sach-thieu-nhi-doraemon-tap-1-50.png"),
67+
Map.entry("sach-giao-khoa/vat-ly-lop-10", "/images/books/generated/sach-giao-khoa-vat-ly-lop-10.png"),
68+
Map.entry("sach-thieu-nhi/alice-o-xu-so-than-tien", "/images/books/generated/sach-thieu-nhi-alice-o-xu-so-than-tien.png"),
69+
Map.entry("tho/harry-potter-va-hon-da-phu-thuy", "/images/books/generated/tho-harry-potter-va-hon-da-phu-thuy.png"),
70+
Map.entry("van-hoc-co-dien/harry-potter-va-hon-da-phu-thuy", "/images/books/generated/van-hoc-co-dien-harry-potter-va-hon-da-phu-thuy.png"),
71+
Map.entry("sach-ngoai-van/1984-george-orwell", "/images/books/generated/sach-ngoai-van-1984-george-orwell.png"),
72+
Map.entry("am-thuc/cam-nang-lam-banh", "/images/books/generated/am-thuc-cam-nang-lam-banh.png"),
73+
Map.entry("sach-ngoai-van/pride-and-prejudice", "/images/books/generated/sach-ngoai-van-pride-and-prejudice.png"),
74+
Map.entry("lanh-dao/khoi-nghiep-tu-san-sau", "/images/books/generated/lanh-dao-khoi-nghiep-tu-san-sau.png"),
75+
Map.entry("sach-thieu-nhi/harry-potter-bo-day-du", "/images/books/generated/sach-thieu-nhi-harry-potter-bo-day-du.png"),
76+
Map.entry("khoa-hoc-tu-nhien/nguoi-dan-duong", "/images/books/generated/khoa-hoc-tu-nhien-nguoi-dan-duong.png")
77+
);
3278

3379
private CatalogDataSeeder() {
3480
}
@@ -354,7 +400,7 @@ private static List<Product> buildSampleProducts(List<Category> categorySlots, L
354400
boolean isFeatured = i < 3;
355401
boolean isBestseller = i < 4;
356402
boolean isNew = i >= 2 && i < 6;
357-
String imageUrl = resolvePlaceholderPath(cat);
403+
String imageUrl = resolveProductImagePath(cat, title);
358404
String shortDesc = buildShortDescription(cat.getName(), author, publisher);
359405
String description = buildLongDescription(cat.getName(), author, publisher, pages);
360406

@@ -535,11 +581,27 @@ private static String resolvePlaceholderPath(Category category) {
535581
};
536582
}
537583

584+
private static String resolveProductImagePath(Category category, String title) {
585+
String generatedCoverPath = resolveGeneratedCoverPath(category, title);
586+
if (generatedCoverPath != null) {
587+
return generatedCoverPath;
588+
}
589+
590+
return resolvePlaceholderPath(category);
591+
}
592+
538593
private static String resolveNormalizedImageUrl(Product product) {
539594
if (product != null && ShowcaseBookCatalog.isCuratedIsbn(product.getIsbn())) {
540595
return ShowcaseBookCatalog.localCoverPath(product.getIsbn());
541596
}
542597

598+
String generatedCoverPath = resolveGeneratedCoverPath(
599+
product != null ? product.getCategory() : null,
600+
product != null ? product.getName() : null);
601+
if (generatedCoverPath != null) {
602+
return generatedCoverPath;
603+
}
604+
543605
if (product != null && product.getImages() != null) {
544606
for (String image : product.getImages()) {
545607
if (image != null && !image.isBlank() && image.startsWith("/")) {
@@ -562,15 +624,22 @@ private static List<String> resolveNormalizedImages(Product product, String norm
562624
images.add(ShowcaseBookCatalog.localCoverPath(product.getIsbn()));
563625
}
564626

627+
String generatedCoverPath = resolveGeneratedCoverPath(
628+
product != null ? product.getCategory() : null,
629+
product != null ? product.getName() : null);
630+
if (generatedCoverPath != null) {
631+
images.add(generatedCoverPath);
632+
}
633+
565634
if (product != null && product.getImages() != null) {
566635
for (String image : product.getImages()) {
567-
if (image != null && !image.isBlank() && image.startsWith("/")) {
636+
if (isReusableLocalImagePath(image, generatedCoverPath)) {
568637
images.add(image);
569638
}
570639
}
571640
}
572641

573-
if (product != null && product.getImageUrl() != null && !product.getImageUrl().isBlank() && product.getImageUrl().startsWith("/")) {
642+
if (product != null && isReusableLocalImagePath(product.getImageUrl(), generatedCoverPath)) {
574643
images.add(product.getImageUrl());
575644
}
576645

@@ -580,4 +649,43 @@ private static List<String> resolveNormalizedImages(Product product, String norm
580649

581650
return List.copyOf(images);
582651
}
652+
653+
private static String resolveGeneratedCoverPath(Category category, String title) {
654+
String key = generatedCoverKey(category, title);
655+
if (key.isBlank()) {
656+
return null;
657+
}
658+
659+
return PRIORITY_GENERATED_COVERS.get(key);
660+
}
661+
662+
private static String generatedCoverKey(Category category, String title) {
663+
if (category == null || title == null || title.isBlank()) {
664+
return "";
665+
}
666+
667+
return slugifyCoverKey(category.getName()) + "/" + slugifyCoverKey(title);
668+
}
669+
670+
private static String slugifyCoverKey(String value) {
671+
if (value == null || value.isBlank()) {
672+
return "";
673+
}
674+
675+
return Normalizer.normalize(value, Normalizer.Form.NFD)
676+
.replaceAll("\\p{M}+", "")
677+
.replace('\u0111', 'd')
678+
.replace('\u0110', 'D')
679+
.toLowerCase(Locale.ROOT)
680+
.replaceAll("[^a-z0-9]+", "-")
681+
.replaceAll("(^-+|-+$)", "");
682+
}
683+
684+
private static boolean isReusableLocalImagePath(String image, String generatedCoverPath) {
685+
if (image == null || image.isBlank() || !image.startsWith("/")) {
686+
return false;
687+
}
688+
689+
return generatedCoverPath == null || !image.contains("/images/books/placeholders/");
690+
}
583691
}

backend/src/main/java/com/bookstore/config/RateLimitingFilter.java

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,8 @@
1515
import java.util.concurrent.atomic.AtomicInteger;
1616

1717
/**
18-
* Rate Limiting Filter để bảo vệ ứng dụng khỏi brute-force attacks và DDoS.
19-
*
20-
* Giới hạn:
21-
* - Auth endpoints: 5 requests/phút/IP
22-
* - API endpoints: 100 requests/phút/IP
23-
* - Public endpoints: 200 requests/phút/IP
18+
* Rate limiting filter for protecting auth and API endpoints from brute-force
19+
* and burst traffic.
2420
*/
2521
@Component
2622
@Order(1)
@@ -29,25 +25,25 @@ public class RateLimitingFilter implements Filter {
2925

3026
private final Map<String, RateLimitEntry> rateLimitMap = new ConcurrentHashMap<>();
3127

32-
@Value("${app.rate-limit.enabled:true}")
28+
@Value("${app.rate-limit.enabled:${rate.limit.enabled:true}}")
3329
private boolean rateLimitEnabled;
3430

35-
@Value("${app.rate-limit.auth-limit:5}")
31+
@Value("${app.rate-limit.auth-limit:${rate.limit.auth.limit:5}}")
3632
private int authLimit;
3733

38-
@Value("${app.rate-limit.auth-window-ms:60000}")
34+
@Value("${app.rate-limit.auth-window-ms:${rate.limit.auth.window.ms:60000}}")
3935
private long authWindowMs;
4036

41-
@Value("${app.rate-limit.api-limit:100}")
37+
@Value("${app.rate-limit.api-limit:${rate.limit.api.limit:100}}")
4238
private int apiLimit;
4339

44-
@Value("${app.rate-limit.api-window-ms:60000}")
40+
@Value("${app.rate-limit.api-window-ms:${rate.limit.api.window.ms:60000}}")
4541
private long apiWindowMs;
4642

47-
@Value("${app.rate-limit.public-limit:200}")
43+
@Value("${app.rate-limit.public-limit:${rate.limit.public.limit:200}}")
4844
private int publicLimit;
4945

50-
@Value("${app.rate-limit.public-window-ms:60000}")
46+
@Value("${app.rate-limit.public-window-ms:${rate.limit.public.window.ms:60000}}")
5147
private long publicWindowMs;
5248

5349
@Override
@@ -66,10 +62,8 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo
6662
String requestUri = request.getRequestURI();
6763
String key = clientIp + ":" + requestUri;
6864

69-
// Xác định loại endpoint để áp dụng limit phù hợp
7065
RateLimitConfig config = getRateLimitConfig(request.getMethod(), requestUri);
7166

72-
// Kiểm tra rate limit
7367
RateLimitEntry entry = rateLimitMap.computeIfAbsent(key, k -> new RateLimitEntry(config.limit, config.windowMs));
7468

7569
if (!entry.tryConsume()) {
@@ -86,7 +80,6 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo
8680
return;
8781
}
8882

89-
// Thêm headers vào response
9083
response.setHeader("X-RateLimit-Limit", String.valueOf(config.limit));
9184
response.setHeader("X-RateLimit-Remaining", String.valueOf(entry.getRemaining()));
9285
response.setHeader("X-RateLimit-Reset", String.valueOf(entry.getResetTime() / 1000));

backend/src/main/java/com/bookstore/config/SecurityConfig.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
public class SecurityConfig {
3838

3939
private static final String DEFAULT_ALLOWED_ORIGINS =
40-
"http://localhost:3000,http://localhost:3001,http://localhost:5173";
40+
"http://localhost:3000,http://localhost:3001,http://localhost:5173,"
41+
+ "http://127.0.0.1:3000,http://127.0.0.1:3001";
4142
static final String CONTENT_SECURITY_POLICY = String.join(" ",
4243
"default-src 'self';",
4344
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com;",

backend/src/main/java/com/bookstore/config/WebMvcConfig.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) {
4343
public void addCorsMappings(CorsRegistry registry) {
4444
// CORS handled by SecurityConfig - this is fallback only
4545
registry.addMapping("/**")
46-
.allowedOriginPatterns("http://localhost:3000", "http://localhost:3001", "http://localhost:5173")
46+
.allowedOriginPatterns(
47+
"http://localhost:3000",
48+
"http://localhost:3001",
49+
"http://localhost:5173",
50+
"http://127.0.0.1:3000",
51+
"http://127.0.0.1:3001")
4752
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
4853
.allowedHeaders("*")
4954
.exposedHeaders("Authorization", "X-Request-ID")

backend/src/main/resources/application-prod.properties

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ jwt.secret=${JWT_SECRET}
4141
# ===========================================
4242
# Rate Limiting Configuration (Production)
4343
# ===========================================
44-
app.rate-limit.enabled=true
45-
app.rate-limit.auth-limit=5
46-
app.rate-limit.auth-window-ms=60000
47-
app.rate-limit.api-limit=100
48-
app.rate-limit.api-window-ms=60000
49-
app.rate-limit.public-limit=200
50-
app.rate-limit.public-window-ms=60000
44+
app.rate-limit.enabled=${RATE_LIMIT_ENABLED:true}
45+
app.rate-limit.auth-limit=${RATE_LIMIT_AUTH_LIMIT:5}
46+
app.rate-limit.auth-window-ms=${RATE_LIMIT_AUTH_WINDOW_MS:60000}
47+
app.rate-limit.api-limit=${RATE_LIMIT_API_LIMIT:100}
48+
app.rate-limit.api-window-ms=${RATE_LIMIT_API_WINDOW_MS:60000}
49+
app.rate-limit.public-limit=${RATE_LIMIT_PUBLIC_LIMIT:200}
50+
app.rate-limit.public-window-ms=${RATE_LIMIT_PUBLIC_WINDOW_MS:60000}
5151

5252
# ===========================================
5353
# Input Validation Configuration (Production)

docker-compose.e2e.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,12 @@ services:
8484
APP_DEMO_MANAGER_PASSWORD: E2ETestDemoManagerPasswordForBookStore123!
8585
APP_DEMO_CUSTOMER_PASSWORD: E2ETestDemoCustomerPasswordForBookStore123!
8686
# CORS
87-
CORS_ORIGINS: http://localhost:3000,http://localhost:3001,http://frontend:3000
87+
CORS_ORIGINS: http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000,http://127.0.0.1:3001,http://frontend:3000
8888
# JPA
8989
JPA_DDL_AUTO: create-drop
9090
# Rate Limiting
9191
RATE_LIMIT_ENABLED: "true"
92-
RATE_LIMIT_AUTH_LIMIT: "10"
92+
RATE_LIMIT_AUTH_LIMIT: "500"
9393
RATE_LIMIT_API_LIMIT: "200"
9494
RATE_LIMIT_PUBLIC_LIMIT: "500"
9595
# Input Validation

frontend/e2e/additional.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,6 @@ test.describe('Performance', () => {
245245
// ============================================
246246

247247
test.describe('Wishlist', () => {
248-
test.use({ storageState: 'storageState.json' } as any);
249-
250248
test('Wishlist button is visible on product card', async ({ page }) => {
251249
await page.goto(`${BASE_URL}/products`);
252250
await page.waitForLoadState('networkidle');

frontend/e2e/admin-portfolio.spec.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,10 @@ async function capture(page: Page, testInfo: TestInfo, name: string) {
3636

3737
async function assertNoMojibake(page: Page) {
3838
const bodyText = await page.locator("body").innerText();
39-
const suspiciousSequences = [
40-
"\u00c3",
41-
"\u00c2",
42-
"\u00c6",
43-
"\u00c4",
44-
"\u00e1\u00ba",
45-
"\u00e1\u00bb",
46-
];
47-
48-
for (const sequence of suspiciousSequences) {
49-
expect(bodyText).not.toContain(sequence);
50-
}
39+
const mojibakePattern =
40+
/[\u00c2\u00c3\u00c4\u00c6][\u0080-\u00bf]|\u00e1[\u00ba\u00bb]|\ufffd/u;
5141

52-
expect(bodyText).not.toContain("\ufffd");
42+
expect(bodyText).not.toMatch(mojibakePattern);
5343
}
5444

5545
async function login(page: Page, email: string, password: string) {

0 commit comments

Comments
 (0)