Danh sách bài viết

Bài 108: Session Refresh Strategy

Token có TTL ngắn không phải vấn đề khi test chạy nhanh — nhưng với long-running suite hoặc CI pipeline chạy sequential nhiều test, token login từ đầu có thể expire giữa chừng khiến các test cuối fail với lỗi authentication. Bài này trình bày 4 strategy xử lý tình huống này cùng với các pitfall cần tránh.

28/05/2026
0 lượt xem
1

Mục Tiêu Bài Học

Sau bài này, bạn sẽ:

  • Giải thích được tại sao token expire là vấn đề đặc thù của long-running test suite.
  • Viết hàm kiểm tra JWT đã expire dựa trên exp claim mà không cần gọi API.
  • Implement strategy verify age của storageState file trước khi reuse.
  • Thiết kế fixture cung cấp callback refreshIfNeeded() thay vì trả về token tĩnh.
  • Gọi refresh endpoint khi app hỗ trợ refresh token flow.
  • Intercept response 401 để tự động re-login.
  • Biết khi nào dùng addLocatorHandler cho session expired modal.
  • Tránh 4 pitfall điển hình về token expire trong test.

Bài này không lặp lại cơ chế login (bài 106–107) hay chi tiết JWT (bài 110). Focus vào strategy xử lý token đã lấy được nhưng có thể expire trong quá trình chạy suite.

2

Vấn Đề: Token Expire Giữa Suite

Phần lớn app production dùng token có TTL (Time-To-Live) — JWT, session cookie, hoặc OAuth access token với expiry từ 5 phút đến vài tiếng. Với test đơn lẻ, TTL dài hơn thời gian chạy test là đủ. Nhưng có 3 tình huống khiến token expire trở thành vấn đề thực tế:

Tình huống 1 — Long-running suite sequential

Suite có 200 test, chạy với workers: 1 (serial). Mỗi test trung bình 15 giây → tổng ~50 phút. Access token có TTL 30 phút → test từ 100 trở đi nhận 401 hoặc redirect login.

Tình huống 2 — storageState saved từ trước

File playwright/.auth/user.json được save vào sáng, chiều chạy lại test mà không chạy setup project → token trong file đã expire từ lúc save.

09:00  setup → login → save user.json  (token exp: 09:15)
...
14:00  test run → load user.json       (token đã expire từ 09:15)
        → mọi request authenticated → 401

Tình huống 3 — CI pipeline chậm

CI queue lâu, job chạy muộn hơn dự kiến. Hoặc test suite mở rộng theo sprint nhưng token TTL không được điều chỉnh tương ứng. Token expire xảy ra không đều — đôi khi fail, đôi khi pass tùy thời điểm job chạy.

Biểu hiện khi token expire

  • Test fail với expect(page).toHaveURL('/dashboard') nhưng thực tế URL là /login.
  • API call trong test trả về 401 — expect(response).toBeOK() fail.
  • Element trên trang không tồn tại do page render dạng guest thay vì authenticated.
  • Lỗi xảy ra ở nhiều test liên tiếp (không phải đơn lẻ) — dấu hiệu của stale auth state.
3

Kiểm Tra JWT Expire

JWT có cấu trúc 3 phần cách nhau bởi dấu chấm: header.payload.signature. Phần payload là Base64url-encoded JSON chứa exp — Unix timestamp (seconds) của thời điểm token hết hạn.

Hàm kiểm tra expire không cần gọi API, chỉ đọc payload của token:

function isExpired(token: string): boolean {
  const payload = JSON.parse(atob(token.split('.')[1]));
  return payload.exp * 1000 < Date.now();
}

Một số lưu ý khi dùng hàm này:

  • atob() — khả dụng trong Node.js từ v16, không cần import thêm.
  • payload.exp có đơn vị giây, Date.now() có đơn vị mili-giây — nhân payload.exp * 1000 để so sánh đúng.
  • Nếu JWT dùng Base64url (thay vì Base64 thuần), cần normalize trước khi decode: token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'). Đa số JWT library đã làm điều này; kiểm tra token thực tế của app khi gặp decode error.
  • Nếu token không phải JWT (ví dụ opaque token), không thể decode — cần gọi API introspect endpoint hoặc dùng chiến lược thay thế như verify age của file.

Thêm buffer thời gian

Nên kiểm tra expire sớm hơn thực tế một khoảng buffer (ví dụ 60 giây) để tránh token expire ngay trong khi test đang chạy giữa chừng:

const BUFFER_MS = 60 * 1000;  // 60 giây buffer

function isExpiredSoon(token: string): boolean {
  const payload = JSON.parse(atob(token.split('.')[1]));
  return payload.exp * 1000 - BUFFER_MS < Date.now();
}
4

Strategy 1 — Verify State Freshness Trước Khi Dùng

Strategy đơn giản nhất: kiểm tra age của storageState file trước khi reuse. Nếu file quá cũ (vượt ngưỡng TTL), thực hiện login mới và lưu đè lên file cũ.

Cách tiếp cận này dùng fs.statSync().mtimeMs — thời điểm file được ghi lần cuối — để ước tính tuổi của token mà không cần parse nội dung JWT:

// tests/setup/auth.setup.ts
import { test as setup } from '@playwright/test';
import fs from 'fs';

setup('login if needed', async ({ page }) => {
  const authFile = 'playwright/.auth/user.json';

  if (fs.existsSync(authFile)) {
    const age = Date.now() - fs.statSync(authFile).mtimeMs;
    if (age < 10 * 60 * 1000) {  // < 10 phút
      return;  // state còn valid, skip login
    }
  }

  // Re-login
  await login(page);
  await page.context().storageState({ path: authFile });
});

Điều chỉnh ngưỡng

Ngưỡng 10 phút trong ví dụ trên phù hợp khi TTL của token là 15 phút — giữ buffer 5 phút. Quy tắc chung: ngưỡng freshness = TTL × 0.6~0.7. Ví dụ:

Token TTL Ngưỡng freshness Buffer
15 phút 10 phút 5 phút
30 phút 20 phút 10 phút
1 tiếng 45 phút 15 phút
8 tiếng 6 tiếng 2 tiếng

Ưu và nhược điểm

  • Ưu: đơn giản, không cần parse JWT, hoạt động với mọi loại token.
  • Nhược: thời điểm ghi file không phải lúc nào cũng đồng nhất với thời điểm token được cấp — nếu login chạy lâu (ví dụ 2FA), token đã "già" hơn file một chút. Ngoài ra, strategy này chỉ kiểm tra trước khi setup — không bảo vệ token expire trong khi suite đang chạy dài.
5

Strategy 2 — Refresh Token Trong Fixture

Thay vì trả về token tĩnh, fixture trả về một callback refreshIfNeeded(). Test gọi callback này mỗi khi cần token — callback tự kiểm tra và refresh nếu token đã expire:

// fixtures/auth.ts
import { test as base } from '@playwright/test';

type AuthFixtures = {
  authToken: () => Promise<string>;
};

export const test = base.extend<AuthFixtures>({
  authToken: async ({ request }, use) => {
    let token = await login(request);

    const refreshIfNeeded = async (): Promise<string> => {
      if (isExpiredSoon(token)) {
        token = await refreshToken(request, token);
      }
      return token;
    };

    await use(refreshIfNeeded);
  },
});

Cách dùng trong test:

test('gọi API với token mới', async ({ request, authToken }) => {
  const token = await authToken();  // tự refresh nếu cần

  const response = await request.get('/api/orders', {
    headers: { Authorization: `Bearer ${token}` },
  });
  await expect(response).toBeOK();
});

Implement hàm refreshToken

Nếu app hỗ trợ refresh token endpoint (xem strategy 3), refreshToken() gọi endpoint đó. Nếu không hỗ trợ, fallback là re-login toàn bộ:

async function refreshToken(
  request: APIRequestContext,
  oldToken: string
): Promise<string> {
  // Nếu app có refresh endpoint
  const storedRefreshToken = getStoredRefreshToken();
  if (storedRefreshToken) {
    const res = await request.post('/api/refresh', {
      data: { refreshToken: storedRefreshToken },
    });
    const { accessToken } = await res.json();
    return accessToken;
  }

  // Fallback: re-login toàn bộ
  return await login(request);
}

Ưu và nhược điểm

  • Ưu: token luôn fresh tại thời điểm test dùng, không cần biết trước suite sẽ chạy bao lâu.
  • Nhược: test phải gọi callback thủ công thay vì dùng token trực tiếp — không tự động. Phù hợp hơn cho test dùng API trực tiếp (request fixture) hơn là test UI.
6

Strategy 3 — Gọi Refresh Endpoint

Khi app implement OAuth 2.0 hoặc bất kỳ refresh token flow nào, có thể đổi access token mới mà không cần nhập lại credential. Đây là cách "sạch" nhất — tương tự cách app thực trên production hoạt động:

// Đổi access token mới bằng refresh token
const res = await request.post('/api/refresh', {
  data: { refreshToken: storedRefreshToken },
});
const { accessToken } = await res.json();

Lưu refresh token khi login lần đầu

Setup project (hoặc fixture lần đầu) cần lưu lại refresh token để dùng về sau:

// global-setup.ts hoặc setup fixture
const loginRes = await request.post('/api/login', {
  data: { email, password },
});
const { accessToken, refreshToken } = await loginRes.json();

// Lưu cả hai token
await fs.promises.writeFile(
  'playwright/.auth/tokens.json',
  JSON.stringify({ accessToken, refreshToken }),
);

Và hàm helper đọc refresh token:

function getStoredRefreshToken(): string | null {
  try {
    const raw = fs.readFileSync('playwright/.auth/tokens.json', 'utf-8');
    return JSON.parse(raw).refreshToken ?? null;
  } catch {
    return null;
  }
}

Cập nhật storageState sau refresh

Sau khi có access token mới, nếu app dùng cookie-based auth, cần inject token vào context để trang web tự động gửi kèm request tiếp theo:

// Sau khi refresh, cập nhật cookie trong context
await context.addCookies([{
  name: 'access_token',
  value: newAccessToken,
  domain: 'app.example.com',
  path: '/',
}]);

Nếu app dùng localStorage (token được set bằng JavaScript), inject qua page.evaluate():

await page.evaluate((token) => {
  localStorage.setItem('access_token', token);
}, newAccessToken);

Ưu và nhược điểm

  • Ưu: nhanh hơn re-login toàn bộ (chỉ 1 HTTP request), không cần tương tác UI.
  • Nhược: app-specific — endpoint, field name, cách app consume token đều khác nhau. Refresh token bản thân cũng có TTL; nếu suite chạy lâu hơn refresh token TTL, cần re-login toàn bộ.
7

Strategy 4 — Re-auth On 401

Thay vì cố gắng predict khi nào token expire, intercept response 401 để trigger re-login:

// Intercept 401, re-login, tiếp tục test
page.on('response', async (response) => {
  if (response.status() === 401) {
    await reLogin(page);
  }
});

Hàm reLogin thực hiện login lại và update storageState:

async function reLogin(page: Page): Promise<void> {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
  await page.getByRole('button', { name: 'Login' }).click();
  await page.waitForURL('/dashboard');
  // Cập nhật auth file để lần sau dùng lại
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
}

Giới hạn của strategy này

Re-auth on 401 có 2 vấn đề cần cân nhắc:

  • Re-login xảy ra sau khi request đã fail — request gây ra 401 đã bị server từ chối. Test cần retry request đó hoặc reload trang sau khi re-auth, không thể tiếp tục từ điểm đang dở.
  • Không phải 401 nào cũng do token expire — có thể do sai permission, resource không tồn tại, hoặc lỗi auth do bug. Intercept mọi 401 để re-login có thể che giấu lỗi thật.

Cách giảm thiểu vấn đề thứ nhất: reload trang sau khi re-auth, sau đó kết hợp với expect.toPass() để retry assertion:

let reLoginAttempted = false;

page.on('response', async (response) => {
  if (response.status() === 401 && !reLoginAttempted) {
    reLoginAttempted = true;
    await reLogin(page);
    await page.reload();
  }
});

Flag reLoginAttempted ngăn vòng lặp vô hạn nếu re-login không thành công và vẫn nhận 401.

Ưu và nhược điểm

  • Ưu: reactive, không cần biết trước TTL — xử lý bất kỳ thời điểm nào token hết hạn.
  • Nhược: request đã fail không được retry tự động; 401 do nguyên nhân khác cũng trigger re-login; thêm phức tạp vào test flow.
8

addLocatorHandler Cho Session Expired Modal

Một số app hiển thị modal "Session expired" khi phát hiện token hết hạn — thường được trigger bởi client-side polling hoặc interceptor của HTTP library. Modal này xuất hiện bất kỳ lúc nào giữa các action của test, làm block mọi interaction tiếp theo.

addLocatorHandler (v1.42+) cho phép đăng ký handler chạy tự động khi một locator cụ thể xuất hiện trên trang:

// Nếu app hiện modal "Session expired"
await page.addLocatorHandler(
  page.getByText('Session expired'),
  async () => {
    await page.getByRole('button', { name: 'Re-login' }).click();
    // Sau khi click, app điều hướng về trang login
    // hoặc hiển thị login inline trong modal
  }
);

Handler được gọi trước mỗi action tiếp theo khi locator matching xuất hiện — không cần code test tự biết modal có xuất hiện hay không.

Bài này chỉ đề cập cách dùng addLocatorHandler cho re-auth modal. Deep dive về API này — noWaitAfter, times option, removeLocatorHandler(), pattern dismiss cookie banner — được cover tại chương D Locator Handlers nâng cao.

9

Khi Nào Dùng Strategy Nào

Không có strategy nào phù hợp cho mọi tình huống. Lựa chọn dựa trên đặc điểm của suite và app:

Tình huống Strategy phù hợp Lý do
Token TTL > thời gian chạy suite Không cần strategy đặc biệt Token không expire trong khi chạy — đơn giản nhất
storageState file được reuse giữa các run Strategy 1 — verify age Kiểm tra freshness trước khi load, không cần parse token
Suite dài, token TTL ngắn (5–15 phút), test dùng API trực tiếp Strategy 2 — refresh callback fixture Token luôn fresh tại thời điểm test gọi; không ảnh hưởng test UI
App có refresh token endpoint Strategy 3 — refresh endpoint Nhanh, không cần UI, giống cách app production hoạt động
App hiển thị "Session expired" modal addLocatorHandler Handler tự động dismiss modal, không cần modify mỗi test
Token TTL không rõ ràng, expire không đoán trước được Strategy 4 — re-auth on 401 Reactive, xử lý mọi thời điểm expire

Kết hợp strategy

Các strategy không loại trừ nhau. Ví dụ thực tế phổ biến:

  • Strategy 1 + 3: verify age trước khi setup; nếu token expire trong suite thì gọi refresh endpoint.
  • Strategy 1 + addLocatorHandler: verify trước khi chạy; nếu modal xuất hiện giữa chừng thì dismiss và re-auth.
  • CI: chạy setup project mỗi build → strategy 1 thừa vì token luôn mới. Nhưng nếu setup thất bại và file cũ được reuse, strategy 1 catch được trường hợp này.
10

Pattern CI vs Local

Hành vi của token expire khác nhau đáng kể giữa môi trường CI và local dev:

CI — Setup project chạy mỗi build

Đây là cấu hình phổ biến và đáng tin cậy nhất: setup project login và lưu state mỗi khi CI job chạy. Token luôn fresh ngay đầu suite. Nếu TTL > thời gian chạy suite, không cần strategy nào thêm.

// playwright.config.ts
export default defineConfig({
  projects: [
    {
      name: 'setup',
      testMatch: /auth\.setup\.ts/,
    },
    {
      name: 'tests',
      dependencies: ['setup'],
      use: { storageState: 'playwright/.auth/user.json' },
    },
  ],
});

Vấn đề chỉ xảy ra khi:

  • CI job chờ lâu trong queue → setup xong từ lâu, test mới bắt đầu → token gần expire.
  • Suite mở rộng đến mức chạy lâu hơn TTL.

Local — Reuse auth file giữa các run

Developer thường chạy test nhiều lần trong ngày, muốn tránh login lại mỗi lần. Strategy 1 (verify age) phù hợp nhất: login mới khi file quá cũ, giữ cache khi còn fresh.

Quyết định đơn giản nhất

Nếu có thể, tăng TTL của token trong môi trường test lên cao hơn thời gian chạy suite dự kiến. Đây là thay đổi đơn giản nhất và loại bỏ hoàn toàn vấn đề. Token TTL ngắn trên production có lý do bảo mật; trên môi trường test isolated thì không có ràng buộc tương tự.

11

Common Pitfalls

1. Token TTL < suite duration mà không có refresh strategy

Test cuối suite fail với 401 hoặc redirect login. Đặc biệt khó debug vì test đầu pass bình thường — chỉ test sau một ngưỡng thời gian nhất định mới fail.

Symptom: test 1-80 PASS, test 81-200 FAIL
Nguyên nhân: token expire sau 20 phút, test 81+ chạy sau 20 phút
Fix: verify age + re-login, hoặc tăng TTL trong test env

2. storageState saved với token đã expire — load vào test fail ngay

File playwright/.auth/user.json được commit vào repo hoặc cache trên CI nhưng không được regenerate mỗi run. Token trong file đã expire từ lần save trước.

// Kiểm tra trước khi dùng — đọc token từ file và verify
const raw = JSON.parse(fs.readFileSync(authFile, 'utf-8'));
const accessToken = raw.origins?.[0]?.localStorage?.find(
  (item: { name: string }) => item.name === 'access_token'
)?.value;

if (accessToken && isExpiredSoon(accessToken)) {
  // Token trong file đã expire → cần login lại
  fs.unlinkSync(authFile);
}

3. Refresh race condition — nhiều worker refresh đồng thời

Khi dùng strategy refresh token với nhiều worker, tất cả workers có thể phát hiện token expire cùng lúc và gọi refresh endpoint đồng thời. Refresh token thường chỉ dùng được một lần (rotation) — worker thứ hai nhận được refresh token đã bị invalidate.

Worker 0: token expire → POST /api/refresh với refreshToken=RT1
Worker 1: token expire → POST /api/refresh với refreshToken=RT1  (cùng lúc)

Worker 0: nhận accessToken=AT2, refreshToken=RT2 → lưu
Worker 1: nhận 400 "refresh token already used" → fail

Fix: dùng worker-scope fixture để mỗi worker refresh độc lập với refresh token riêng (đã login per-worker từ bài 104). Hoặc implement file lock để chỉ một worker refresh tại một thời điểm.

4. Ngưỡng freshness sai — login không cần thiết hoặc quá muộn

Ngưỡng quá thấp so với TTL: login lại quá thường xuyên, làm chậm suite và tăng tải auth server.

Ngưỡng quá cao so với TTL: token expire trong khi suite đang chạy — strategy 1 không giúp được.

// Ví dụ sai: TTL=15 phút nhưng ngưỡng=14 phút
// Chỉ còn 1 phút buffer — không đủ cho test dài
const age = Date.now() - fs.statSync(authFile).mtimeMs;
if (age < 14 * 60 * 1000) return;  // ← buffer quá mỏng

// Nên dùng: ngưỡng ≤ TTL × 0.7 để có buffer đủ lớn
if (age < 10 * 60 * 1000) return;  // buffer 5 phút với TTL=15 phút
12

Tổng Kết

  • Token expire là vấn đề đặc thù của long-running suite: test đầu pass, test cuối fail với 401 hoặc redirect login.
  • Hàm isExpired(token) decode JWT payload để đọc exp claim — không cần API call, khả dụng trong Node.js từ v16.
  • Strategy 1: kiểm tra age của storageState file (fs.statSync().mtimeMs) trước khi reuse — đơn giản, không cần parse token.
  • Strategy 2: fixture trả về callback refreshIfNeeded() — token luôn fresh tại thời điểm test dùng; phù hợp test API.
  • Strategy 3: gọi refresh endpoint khi app hỗ trợ — nhanh, không cần UI, nhưng app-specific.
  • Strategy 4: intercept 401 để re-login — reactive; cần flag chống retry loop và xử lý request đã fail.
  • addLocatorHandler xử lý "Session expired" modal xuất hiện bất kỳ lúc nào — deep dive tại chương D.
  • Giải pháp đơn giản nhất: tăng TTL trong test environment để lớn hơn suite duration.
  • Race condition khi nhiều worker refresh token chung cùng lúc — cần dùng per-worker token hoặc file lock.
13

Bài Tập Củng Cố

Câu 1

JWT payload (sau khi decode) có nội dung: { "sub": "u123", "exp": 1748430000 }. Hiện tại là Unix timestamp 1748425000 (giây). Token đã expire chưa? Tính thời gian còn lại.

Đáp án

Chưa expire. Thời gian còn lại: (1748430000 - 1748425000) × 1000 = 5.000.000 ms = 83 phút 20 giây.

Hàm isExpired: 1748430000 * 1000 = 1748430000000 ms so với Date.now() = 1748425000000 ms1748430000000 < 1748425000000false → chưa expire.

Câu 2

Suite có 150 test, chạy sequential (workers: 1), mỗi test trung bình 20 giây. Token TTL là 45 phút. Cần dùng session refresh strategy không? Nếu cần, strategy nào phù hợp nhất?

Đáp án

Thời gian chạy suite: 150 × 20 = 3000 giây = 50 phút. TTL = 45 phút < 50 phút → token sẽ expire trước khi suite kết thúc (~test 135 trở đi).

Strategy phù hợp: tùy app — nếu app hỗ trợ refresh endpoint, dùng strategy 3. Nếu không, dùng strategy 1 (verify age trong setup project) kết hợp strategy 2 (refresh callback fixture) để handle token expire giữa suite. Hoặc đơn giản nhất: tăng TTL trong test env lên > 50 phút.

Câu 3

Đoạn code sau có vấn đề gì?

page.on('response', async (response) => {
  if (response.status() === 401) {
    await reLogin(page);
  }
});
Đáp án

Hai vấn đề:

  1. Không có flag chống loop: nếu reLogin() thất bại (ví dụ sai credential, server login cũng trả 401), handler tiếp tục fire cho mọi 401 tiếp theo → vòng lặp vô hạn cho đến khi test timeout.
  2. Request đã fail không được retry: handler chạy sau khi response 401 đã nhận. Trang có thể đã render dạng guest state. Test cần reload trang hoặc retry action sau khi re-auth, không thể tự động tiếp tục từ điểm dở.

Fix: thêm flag reLoginAttempted, chỉ re-login một lần; gọi page.reload() sau re-auth.

Câu 4

App dùng cookie-based auth. Refresh endpoint POST /api/refresh trả về { "accessToken": "..." } nhưng không set cookie tự động — access token cần được inject thủ công. Viết đoạn code thực hiện refresh và inject token vào context hiện tại.

Đáp án
async function refreshAndInject(
  request: APIRequestContext,
  context: BrowserContext
): Promise<void> {
  const refreshToken = getStoredRefreshToken();
  if (!refreshToken) throw new Error('No refresh token stored');

  const res = await request.post('/api/refresh', {
    data: { refreshToken },
  });
  if (!res.ok()) throw new Error(`Refresh failed: ${res.status()}`);

  const { accessToken } = await res.json();

  // Inject vào cookie của context
  await context.addCookies([{
    name: 'access_token',
    value: accessToken,
    domain: 'app.example.com',
    path: '/',
  }]);
}

Câu 5

Suite chạy với workers: 4. Tất cả workers dùng chung 1 refresh token. Token có TTL 10 phút. Sau 10 phút tất cả 4 workers phát hiện token expire đồng thời. Vấn đề gì xảy ra? Giải pháp nào giải quyết triệt để nhất?

Đáp án

Tất cả 4 workers gọi POST /api/refresh với cùng refresh token cùng lúc. Nếu app dùng refresh token rotation (1-time use), chỉ 1 worker thành công — 3 workers còn lại nhận lỗi "invalid or already used refresh token".

Giải pháp triệt để nhất: dùng per-worker auth (bài 104) — mỗi worker login với account riêng, có access token và refresh token riêng. Refresh của worker 0 không ảnh hưởng đến refresh token của worker 1, 2, 3. Không có race condition.

Giải pháp thay thế nếu không thể dùng per-worker account: implement file lock (distributed lock qua file system) để chỉ 1 worker refresh tại một thời điểm; workers còn lại đợi và dùng token mới từ file.

14

Bài Tiếp Theo

Bài 109 đề cập cách mock SSO và OAuth flow trong test — xử lý redirect chain, fake authorization server response, và inject token trực tiếp không qua browser flow.

Bài 109: SSO / OAuth Mock Pattern