1. Bối cảnh nghiệp vụ: SaaS đa tenant tính tiền theo mức dùng
Một nền tảng SaaS B2B phục vụ ba nghìn tổ chức khách hàng (tenant) trên cùng một hạ tầng dùng chung. Mỗi tenant tự quản lý người dùng, phân quyền và bị tính tiền theo mức sử dụng thực tế: số lời gọi API, dung lượng lưu trữ, số ghế người dùng hoạt động. Ba trụ cột chất lượng sống còn là cô lập dữ liệu giữa các tenant, tính hoá đơn theo usage phải đúng đến từng xu, và phân quyền RBAC phải được thực thi ở tầng API chứ không chỉ ẩn nút. Sai một trong ba đều là sự cố nghiêm trọng.
SLA nghiệp vụ đặt ra ba cam kết cứng. Thứ nhất, không bao giờ có rò rỉ dữ liệu chéo giữa tenant — đây là cam kết bảo mật tuyệt đối, một lần rò là mất khách và có thể vi phạm quy định bảo vệ dữ liệu. Thứ hai, hoá đơn phải khớp chính xác với usage đã ghi nhận, kể cả khi khách nâng hoặc hạ gói giữa kỳ (proration). Thứ ba, quy trình đòi nợ (dunning) khi thanh toán thất bại phải chạy đúng lịch và không khoá nhầm tài khoản đang trả tiền đầy đủ.
Chiến lược kiểm thử của bài này dựa trên ba kỹ thuật gắn với ba trụ cột. Với RBAC và đa tenant, ta dùng storageState cho từng cặp vai trò và tenant để đăng nhập sẵn, kết hợp kiểm thử phủ định uỷ quyền — cố tình truy cập tài nguyên của tenant khác và khẳng định bị chặn. Với billing, ta seed usage qua API rồi khẳng định hoá đơn tính lại bằng oracle độc lập khớp đến từng xu. Toàn bộ tuân theo nguyên tắc oracle-first: không khẳng định giao diện đẹp, mà khẳng định bất biến bảo mật và tiền.
2. Kiến trúc và mô hình dữ liệu đa tenant
Hệ thống dùng mô hình shared-database, shared-schema: mọi tenant chung một cơ sở dữ liệu, mỗi bản ghi mang một cột tenant_id. Mô hình này tiết kiệm chi phí nhưng đặt gánh nặng cô lập lên tầng ứng dụng: mọi truy vấn phải lọc theo tenant_id của phiên hiện tại, thường thông qua một lớp middleware gán ngữ cảnh tenant. Đây chính là điểm rủi ro số một: chỉ cần một truy vấn quên mệnh đề lọc tenant là dữ liệu rò rỉ chéo. Vì vậy oracle cô lập phải được kiểm ở mọi endpoint đọc dữ liệu.
// prisma/schema.prisma (trích) — mọi bảng có tenantId, index kép để lọc nhanh.
model Tenant {
id String @id @default(cuid())
name String
plan String // FREE | PRO | ENTERPRISE
users User[]
usages Usage[]
invoices Invoice[]
}
model User {
id String @id @default(cuid())
email String
role String // ADMIN | MEMBER | BILLING
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, email])
}
model Usage {
id String @id @default(cuid())
tenantId String
metric String // api_calls | storage_gb | seats
quantity Int
at DateTime
@@index([tenantId, metric, at]) // luôn lọc theo tenant TRƯỚC
}
model Invoice {
id String @id @default(cuid())
tenantId String
periodEnd DateTime
amount Int // đơn vị: cent, tránh số thực
status String // OPEN | PAID | PAST_DUE | VOID
}Từ mô hình này ta rút ra bất biến cô lập cốt lõi: với mọi endpoint và mọi cặp tenant khác nhau, dữ liệu trả về không bao giờ chứa bản ghi thuộc tenant khác. Bất biến billing: tổng tiền hoá đơn của một tenant bằng tổng usage đã ghi nhận nhân đơn giá theo gói, cộng phần proration nếu đổi gói. Và bất biến RBAC: một hành động chỉ thành công khi vai trò của người gọi có quyền tương ứng trong ma trận, kiểm ngay ở API. Ba bất biến này là oracle xuyên suốt bài.
3. storageState theo vai trò và tenant
Đăng nhập lại trong từng test vừa chậm vừa dễ giòn. Playwright giải quyết bằng storageState: ta đăng nhập một lần trong bước setup, lưu cookie và token vào tệp JSON, rồi các test nạp sẵn trạng thái đó. Với hệ thống nhiều vai trò và nhiều tenant, ta tạo một ma trận storageState: admin của tenant A, member của tenant A, admin của tenant B, và người dùng billing. Nhờ đó mỗi test chỉ việc chọn đúng danh tính cần thiết mà không phải trả giá đăng nhập lặp lại.
// tests/auth.setup.ts — tạo storageState cho từng (vai trò, tenant).
import { test as setup } from '@playwright/test';
const identities = [
{ key: 'adminA', email: 'admin@acme.test', tenant: 'A', role: 'ADMIN' },
{ key: 'memberA', email: 'member@acme.test', tenant: 'A', role: 'MEMBER' },
{ key: 'adminB', email: 'admin@globex.test', tenant: 'B', role: 'ADMIN' },
{ key: 'billingA', email: 'billing@acme.test', tenant: 'A', role: 'BILLING' },
];
for (const id of identities) {
setup(`đăng nhập ${id.key}`, async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(id.email);
await page.getByLabel('Mật khẩu').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Đăng nhập' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: `.auth/${id.key}.json` });
});
}
💬 Bình luận (0)
Chưa có bình luận nào. Hãy là người đầu tiên!