Nhận OTP tự động qua webhook cho test Playwright trong CI/CD
Nhận OTP tự động qua webhook giúp test E2E trên CI/CD hết cảnh treo chờ SMS rồi đỏ vì timeout. Bài này dựng bộ test Playwright gồm helper thuê số ảo, server lọc activationId và job GitHub Actions.
Nhận OTP tự động: vì sao pipeline hay chết ở bước xác minh SMS
Hầu như luồng đăng ký nào cũng có bước nhập mã xác minh, và đúng chỗ đó test E2E trên CI thường đứng hình. Runner là container trong cloud, không có SIM, nên app gửi SMS thì chẳng ai nhận. Test chờ hết timeout rồi đỏ, hoặc có người phải đọc mã trên điện thoại rồi nhập tay.
Ba kiểu chữa cháy hay gặp: khai sẵn vài số test kèm mã cố định trong console của dịch vụ xác thực (auth provider), chạy song song là dính rate limit; cắm một chiếc điện thoại Android cũ vào server để chuyển tiếp SMS, rồi máy rớt Wi-Fi; hoặc thêm cờ tắt xác minh kiểu if (process.env.SKIP_OTP) vào backend. Cách cuối nhanh nhất mà cũng nguy hiểm nhất: cấu hình nhầm một lần là cờ đó bật luôn trên production.
Bài trước đã bàn luồng bốn bước và cách poll mã qua REST. Bài này đi tiếp, đưa luồng đó vào CI/CD theo mô hình sự kiện: test không phải đi hỏi API nữa, chỉ ngồi chờ webhook OTP gõ cửa.
Ba cấp độ test tự động xác minh SMS trong CI/CD
Không phải bài test tự động cho bước xác minh SMS nào cũng cần đến số thuê. Chọn cấp độ trước khi code:
| Cấp độ | Phù hợp khi | Rủi ro | Chi phí |
|---|---|---|---|
| 1. Mock hoặc sandbox của SMS gateway | Test UI, chạy mỗi commit, cần nhanh | Không bắt được lỗi tích hợp thật; mock lệch dần khỏi API thật | Thời gian dựng và bảo trì mock |
| 2. Đọc mã từ DB hoặc Redis của staging | Backend do bạn kiểm soát, CI được vào mạng nội bộ | SecOps thường chặn runner cloud vào DB; test phải ôm thêm connection pool | Công sức viết lớp kết nối tới DB/Redis |
| 3. Số thuê qua API nhận SMS + webhook (số di động thật do dịch vụ cấp, không phải mock) | Test trước release, tích hợp bên thứ ba, kiểm tra đầu số từng quốc gia | Phụ thuộc mạng viễn thông; SMS về chậm thì test chạy lâu | Tiền thuê số mỗi lần chạy, tùy dịch vụ và quốc gia |
Hợp lý nhất là dùng cả ba: cấp 1 cho pull request, cấp 2 nếu hạ tầng cho phép, cấp 3 cho pipeline chạy đêm hoặc trước release. Bài này bàn cấp 3: nhận OTP tự động bằng số thuê qua API và webhook.
Mô hình webhook OTP: ai gọi ai
Với polling, test thuê số ảo rồi cứ vài giây lại hỏi “có mã chưa”. Với webhook, chiều gọi đảo ngược: SMS về đến số thuê, nhà cung cấp POST tin nhắn vào URL HTTPS đã đăng ký, còn test chỉ việc await một promise.
Mình lấy HeroSMS làm ví dụ vì webhook được mô tả rõ trong tài liệu API của HeroSMS; nhà cung cấp khác có webhook cũng ghép được vào kiến trúc này. Vài điểm trong đặc tả quyết định cách thiết kế:
- URL webhook được khai trong cài đặt tài khoản, tối đa 3 URL, không truyền theo từng request. Nghĩa là mọi SMS của tài khoản, kể cả của job khác chạy song song, đều đổ về một chỗ.
- Payload là JSON gồm
activationId,service,text,code(có thể null),country,receivedAt. - Server của bạn phải trả 200 trong 3 giây. Không trả thì họ gửi lại ít nhất 7 lần, cách nhau 20–30 giây, kéo dài tối thiểu 3 phút.
- Request đến từ hai IP cố định: 84.32.223.53 và 185.138.88.87, tiện để whitelist.
Từ đó rút ra ba quyết định: lọc theo activationId chứ không vớ mã đầu tiên bay tới; trả 200 vô điều kiện, kể cả payload không phải của mình hay SMS đã xử lý rồi; và tách server nhận webhook ra khỏi process của test worker, vì Playwright chạy nhiều worker mà cổng thì chỉ có một. Kiến trúc gồm ba file trong tests/: webhook-inbox.ts, otp-provider.ts và otp.spec.ts.
Helper thuê số ảo và teardown không đốt tiền
Phần thuê số dùng endpoint handler_api.php tương thích với giao thức handler_api mà nhiều dịch vụ số ảo dùng chung. Chỉ cần hai thao tác: getNumberV2 để thuê, mỗi lần thuê là một activation (phiên thuê số), và setStatus để đóng phiên; endpoint và API key đọc từ biến môi trường, không nằm trong code.
// tests/otp-provider.ts
import { test as base, expect } from '@playwright/test';
import { waitForOtp } from './webhook-inbox';
// Object trả về của getNumberV2 (theo tài liệu API)
export interface Activation {
activationId: string;
phoneNumber: string;
activationCost: number;
activationEndTime: string;
canGetAnotherSms: boolean;
}
// Endpoint dùng GET + query string; khi lỗi trả chuỗi thuần (NO_NUMBERS, BAD_KEY...)
async function callApi<T>(params: Record<string, string | number>): Promise<T | string> {
// HEROSMS_API_URL là endpoint /stubs/handler_api.php (xem tài liệu API), đọc từ env như API key
const apiUrl = process.env.HEROSMS_API_URL;
const apiKey = process.env.HEROSMS_API_KEY;
if (!apiUrl || !apiKey) throw new Error('Thiếu HEROSMS_API_URL hoặc HEROSMS_API_KEY trong môi trường');
const url = new URL(apiUrl);
url.searchParams.set('api_key', apiKey);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, String(value));
const raw = await (await fetch(url)).text();
try {
return JSON.parse(raw) as T;
} catch {
return raw; // không phải JSON => mã lỗi dạng chuỗi
}
}
// service: mã dịch vụ từ getServicesList; country: ID quốc gia từ getCountries
export async function rentNumber(service: string, country: number): Promise<Activation> {
const data = await callApi<Activation>({ action: 'getNumberV2', service, country });
if (typeof data === 'string') throw new Error(`Không thuê được số: ${data}`);
return data;
}
// status 6 = hoàn tất activation (đã dùng mã), 8 = hủy activation (chưa dùng mã)
export async function closeActivation(activationId: string, status: 6 | 8): Promise<void> {
// Nuốt lỗi có chủ ý: teardown không được làm test fail thêm lần nữa
await callApi({ action: 'setStatus', id: activationId, status }).catch(() => {});
}
type OtpFixture = {
phoneNumber: string;
activationId: string;
waitForCode: (timeoutMs?: number) => Promise<string>;
markUsed: () => void;
};
export const test = base.extend<{ otp: OtpFixture }>({
otp: async ({}, use) => {
// Kiểm tra env trước khi tiêu tiền: Number(undefined) là NaN, gửi lên API chỉ nhận lỗi khó hiểu
const service = process.env.OTP_SERVICE;
const country = Number(process.env.OTP_COUNTRY);
if (!service || !Number.isInteger(country)) {
throw new Error('Cần OTP_SERVICE (mã dịch vụ) và OTP_COUNTRY (ID quốc gia dạng số)');
}
const activation = await rentNumber(service, country);
let used = false;
await use({
phoneNumber: activation.phoneNumber,
activationId: activation.activationId,
waitForCode: (timeoutMs = 120_000) => waitForOtp(activation.activationId, timeoutMs),
markUsed: () => { used = true; },
});
// Chạy cả khi test fail hoặc timeout: đã dùng mã thì hoàn tất (6), chưa thì hủy (8)
await closeActivation(activation.activationId, used ? 6 : 8);
},
});
export { expect };
Phần sau use() của Playwright fixture chạy kể cả khi test fail hay bị timeout, nên phiên thuê luôn được đóng: đã dùng mã thì báo hoàn tất (status 6), chưa thì hủy (status 8). Nếu pipeline chết cứng đến mức teardown không kịp chạy thì còn một lưới an toàn: phiên chỉ kéo dài 20 phút, không có SMS thì tiền tự về số dư.
Server nhận webhook lọc theo activationId
// tests/webhook-inbox.ts
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
// Payload của webhook "incoming SMS" theo tài liệu API
export interface SmsWebhookPayload {
activationId: string;
service: string;
text: string | null;
code?: string | null;
country: number;
receivedAt: string;
}
const INBOX_DIR = process.env.OTP_INBOX_DIR ?? path.resolve('.otp-inbox');
const PORT = Number(process.env.WEBHOOK_PORT ?? 3000);
// activationId đến từ bên ngoài: chỉ giữ ký tự an toàn để không bị path traversal
function inboxFile(activationId: string): string {
return path.join(INBOX_DIR, `${activationId.replace(/[^\w-]/g, '_')}.json`);
}
export function startWebhookServer(): http.Server {
fs.mkdirSync(INBOX_DIR, { recursive: true });
const server = http.createServer((req, res) => {
if (req.method !== 'POST') {
res.writeHead(405).end();
return;
}
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
try {
const payload = JSON.parse(body) as SmsWebhookPayload;
// Ép về chuỗi để ghép tên file an toàn
const id = String(payload.activationId ?? '');
if (!id) throw new Error('thiếu activationId');
// Mỗi activation một "hộp thư" riêng; SMS của job khác cũng được ghi lại, không ảnh hưởng ai
fs.writeFileSync(inboxFile(id), JSON.stringify(payload));
} catch (err) {
// Chỉ log tên lỗi: message của SyntaxError có thể chứa một đoạn body, tức nội dung SMS
console.warn('[webhook] payload không hợp lệ:', err instanceof Error ? err.name : 'unknown');
}
// Luôn trả 200 trong 3 giây, kể cả tin trùng hay tin hỏng, để phía gửi không retry
res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
});
});
server.listen(PORT, () => console.log(`[webhook] đang nghe cổng ${PORT}`));
return server;
}
// Chờ đúng activation của mình, không gọi API lần nào
export function waitForOtp(activationId: string, timeoutMs: number): Promise<string> {
const file = inboxFile(activationId);
return new Promise((resolve, reject) => {
let watcher: fs.FSWatcher | undefined;
let timer: NodeJS.Timeout | undefined;
const cleanup = () => {
watcher?.close();
if (timer) clearTimeout(timer);
};
const tryRead = () => {
if (!fs.existsSync(file)) return;
try {
const payload = JSON.parse(fs.readFileSync(file, 'utf8')) as SmsWebhookPayload;
// Theo tài liệu, code có thể null hoặc vắng mặt: khi đó nhặt 4–8 chữ số từ text
const code = payload.code ?? payload.text?.match(/\d{4,8}/)?.[0];
if (!code) return;
cleanup();
resolve(code);
} catch {
/* file đang ghi dở, chờ sự kiện tiếp theo */
}
};
fs.mkdirSync(INBOX_DIR, { recursive: true });
watcher = fs.watch(INBOX_DIR, (_event, name) => {
if (name === path.basename(file)) tryRead();
});
timer = setTimeout(() => {
cleanup();
reject(new Error(`Không nhận được OTP cho activation ${activationId} sau ${timeoutMs} ms`));
}, timeoutMs);
// Kiểm tra một lần sau khi đã gắn watcher, phòng SMS về trước khi test kịp chờ
tryRead();
});
}
// Chạy trực tiếp: npx tsx tests/webhook-inbox.ts
if (process.argv[1]?.endsWith('webhook-inbox.ts')) startWebhookServer();
Server nhận webhook OTP có hai chỗ đáng bàn. Thứ nhất, server không giữ mã trong bộ nhớ mà ghi ra đĩa theo activationId. Playwright chạy mỗi worker trong một process riêng, không chia sẻ biến, nên hộp thư trên đĩa là cách đơn giản nhất để nhiều worker dùng chung một server. Thứ hai, server trả 200 cho mọi POST, kể cả payload hỏng, vì trả 4xx chỉ khiến phía gửi retry thêm ít nhất 7 lần.
Để server sẵn sàng trước khi worker khởi động, khai webServer: { command: 'npx tsx tests/webhook-inbox.ts', port: 3000, reuseExistingServer: true } trong playwright.config.ts.
Ghép vào test Playwright: nhận OTP tự động, không poll
// tests/otp.spec.ts
import { test, expect } from './otp-provider';
test('đăng ký bằng số thuê qua API, mã về qua webhook', async ({ page, otp }) => {
await page.goto('/signup');
await page.getByLabel('Số điện thoại').fill(otp.phoneNumber);
await page.getByRole('button', { name: 'Gửi mã' }).click();
// SMS quốc tế có thể về chậm hơn nhiều so với nội địa; đặt timeout ngắn là tự rước flaky vào người
const code = await otp.waitForCode(120_000);
otp.markUsed(); // mã đã dùng, bước sau có fail vẫn phải báo hoàn tất
await page.getByLabel('Mã xác minh').fill(code);
await page.getByRole('button', { name: 'Xác nhận' }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
Test không biết gì về HTTP hay API nhận SMS, chỉ thấy một số điện thoại và một hàm chờ mã. Ai cần viết thêm test tự động xác minh SMS chỉ việc import test từ otp-provider. Nhớ nâng timeout của test trong config lên trên 120 giây, nếu không Playwright cắt test trước khi mã về, suite lại flaky; đồng thời khai use.baseURL trỏ tới app đang test, vì khi webServer chỉ khai port, Playwright sẽ tự lấy cổng đó làm baseURL mặc định, tức là mọi page.goto('/signup') sẽ trỏ thẳng vào server webhook.
Đưa lên GitHub Actions: secrets, cổng và tunnel
Muốn nhận OTP tự động trên runner của GitHub Actions thì vướng một chuyện: runner không nhận kết nối từ ngoài vào, mà URL webhook lại phải khai cố định trong tài khoản. Cách gọn nhất là mở tunnel có domain cố định bằng ngrok hoặc cloudflared, khai domain đó một lần trong cài đặt webhook, rồi mỗi job chỉ việc dựng tunnel trỏ vào cổng webhook.
# .github/workflows/e2e-otp.yml
name: e2e-otp
on:
workflow_dispatch: # bấm tay hoặc theo lịch: mỗi lần chạy đều thuê số và tốn tiền thật
schedule:
- cron: '0 2 * * 1-5'
# Một domain tunnel chỉ trỏ về một runner, nên không cho hai job chạy chồng lên nhau
concurrency:
group: e2e-otp
cancel-in-progress: false
jobs:
e2e:
runs-on: ubuntu-latest
env:
WEBHOOK_PORT: 3000
OTP_INBOX_DIR: ${{ runner.temp }}/otp-inbox
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Cài agent ngrok
# cài ngrok theo hướng dẫn chính thức của ngrok
# (gói apt hoặc tải binary); self-hosted runner đã có sẵn ngrok thì bỏ bước này
run: ngrok version
- name: Mở tunnel tới cổng webhook
env:
NGROK_AUTHTOKEN: ${{ secrets.NGROK_AUTHTOKEN }} # token tunnel cũng là secret
WEBHOOK_DOMAIN: ${{ secrets.WEBHOOK_DOMAIN }} # domain cố định đã khai trong cài đặt webhook
run: |
# Chỉ nhận kết nối từ hai IP nguồn của webhook
ngrok http "$WEBHOOK_PORT" \
--domain "$WEBHOOK_DOMAIN" \
--cidr-allow 84.32.223.53/32 --cidr-allow 185.138.88.87/32 \
--log stdout > "$RUNNER_TEMP/ngrok.log" &
sleep 3
- name: Chạy test E2E có xác minh SMS
env:
HEROSMS_API_URL: ${{ vars.HEROSMS_API_URL }} # endpoint handler_api.php, xem tài liệu API
HEROSMS_API_KEY: ${{ secrets.HEROSMS_API_KEY }} # không bao giờ nằm trong repo
OTP_SERVICE: ${{ vars.OTP_SERVICE }}
OTP_COUNTRY: ${{ vars.OTP_COUNTRY }}
run: npx playwright test tests/otp.spec.ts --workers=2
Vài chi tiết:
- Bước cài ngrok trong YAML: làm theo hướng dẫn chính thức của ngrok (gói apt hoặc binary), còn
ngrok versionchỉ xác nhận binary đã lên PATH. - API key và token tunnel để trong GitHub Secrets, đưa vào runner qua
env. Không commit, không echo ra log. Bên GitLab CI thì dùng CI/CD variables và bật tuỳ chọn Masked để giá trị không lộ ra log. - Giới hạn IP bằng
--cidr-allowtuỳ gói ngrok bạn dùng; nếu không có thì bỏ hai cờ đó và whitelist ở firewall như mục Bảo mật bên dưới. concurrencychỉ cho một job chạy mỗi lúc, vì một domain chỉ trỏ về một tunnel; cần thêm job song song thì thêm domain, nhưng tối đa chỉ có 3 URL webhook. Song song trong một job thì chỉ cần tăng--workers, miễn kho số của quốc gia đó còn đủ cho từng worker.- Workflow chạy theo lịch hoặc bấm tay, không chạy mỗi commit.
Thử ở máy local trước khi lên CI: mở ngrok hoặc localtunnel cho cổng 3000, dán URL tạm vào cài đặt webhook rồi chạy npx playwright test. Ngrok báo POST trả 200 mà test vẫn timeout thì lỗi nằm ở so khớp activationId, không phải ở mạng. Xong thì gỡ URL tạm.
Bảo mật: những thứ không được làm
- Không cài cờ tắt OTP trong backend, kể cả “chỉ cho staging”. Cờ đó còn làm test xanh mà chẳng kiểm chứng gì ở khâu SMS: gateway đổi API hay hết credit thì pipeline vẫn xanh. Cần chế độ test thì đặt ở tầng SMS gateway (cấp 1).
- Whitelist hai IP nguồn ở firewall hoặc trên tunnel, đừng kiểm tra IP trong Node: sau tunnel hay reverse proxy,
remoteAddresslà địa chỉ proxy, cònX-Forwarded-Forthì ai cũng gửi được. - Không in
textcủa webhook ra log CI: log tồn tại lâu và nhiều người đọc được. - Coi
activationIdlà dữ liệu bên ngoài: lọc ký tự trước khi ghép thành tên file, nhưinboxFileđã làm. - Số thuê chỉ dùng cho tài khoản test. Tài khoản ngân hàng, ví chính, dịch vụ gắn danh tính thì dùng số chính chủ.
Ví dụ hoàn chỉnh chạy được (giấy phép MIT) có kèm giải thích từng quyết định thiết kế.
FAQ
Nhiều worker và nhiều job chạy cùng lúc, có nhận nhầm mã của nhau không?
Không, miễn là mọi thứ lọc theo activationId. Webhook là của cả tài khoản nên server thấy hết, nhưng mỗi test chỉ chờ đúng file mang id của phiên mình thuê.
Webhook OTP về nhưng code là null thì sao?
Theo tài liệu, code có thể null hoặc vắng mặt, còn text (khi không null) chứa nội dung SMS, nên waitForOtp nhặt chuỗi 4–8 chữ số từ đó; cả hai đều trống thì chờ tiếp tới timeout.
Không muốn phụ thuộc vào tunnel thì có cách nào?
Dùng self-hosted runner nhận được kết nối từ ngoài, hoặc đặt server webhook lên một VPS nhỏ và cho runner đọc hộp thư qua HTTPS có xác thực. Kiến trúc không đổi: vẫn là nhận OTP tự động qua webhook, chỉ khác chỗ đặt hộp thư.
All rights reserved