From 2180aac36abb032aaa814c52dbac2f69d339354e Mon Sep 17 00:00:00 2001 From: Sudip Dadhaniya <61381229+sudip-d@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:53:10 +0530 Subject: [PATCH 1/2] Playwright setup --- playwright.config.ts | 52 ++++++++++++++++ tests/admin-login-auth-app.spec.ts | 97 ++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 playwright.config.ts create mode 100644 tests/admin-login-auth-app.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..5647e027 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,52 @@ +import { defineConfig, devices } from '@playwright/test'; + +declare module '@playwright/test' { + interface PlaywrightTestOptions { + adminURL: string; + adminUser: string; + adminPassword: string; + adminTOTPSecret: string; + } +} +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + baseURL: 'http://localhost:8888/', + trace: 'on-first-retry', + // Admin credentials for admin tests (override via env in CI if needed) + adminURL: 'http://localhost:8888/wp-admin/', + adminUser: 'admin', + adminPassword: 'password', + // Admin TOTP secret (base32) used by injectTOTP helper + adminTOTPSecret: 'MRMHA4LTLFMEMPBOMEQES7RQJQ2CCMZX', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], +}); diff --git a/tests/admin-login-auth-app.spec.ts b/tests/admin-login-auth-app.spec.ts new file mode 100644 index 00000000..71d811a8 --- /dev/null +++ b/tests/admin-login-auth-app.spec.ts @@ -0,0 +1,97 @@ +import { test } from '@playwright/test'; + +test('Admin login)', async ({ page, context }) => { + const info = test.info(); + const use = (info.project && (info.project as any).use) || {}; + const ADMIN_URL = (use.adminURL as string) || (use.baseURL as string) || ''; + const USERNAME = (use.adminUser as string) || ''; + const PASSWORD = (use.adminPassword as string) || ''; + + await page.goto(ADMIN_URL, { waitUntil: 'domcontentloaded' }); + + const usernameSelector = 'input[name="log"], input#user_login, input[aria-label="Username or Email Address"]'; + const passwordSelector = 'input[name="pwd"], input#user_pass, input[aria-label="Password"]'; + + await page.locator(usernameSelector).first().waitFor({ state: 'visible', timeout: 15000 }); + await page.locator(usernameSelector).first().fill(USERNAME); + await page.locator(passwordSelector).first().fill(PASSWORD); + + // Click login and wait for either a new page, auth code input, or a redirect to /wp-admin + await page.getByRole('button', { name: 'Log In' }).click(); + + const newPagePromise = context.waitForEvent('page').then(p => ({ type: 'new', page: p })); + const authPromise = page.waitForSelector('#authcode', { timeout: 30000 }).then(() => ({ type: 'auth' })).catch(() => null); + const navPromise = page.waitForURL(/.*\/wp-admin.*$/, { timeout: 30000 }).then(() => ({ type: 'nav' })).catch(() => null); + + let result = null; + try { + result = await Promise.race([newPagePromise, authPromise, navPromise]) as any; + } catch (e) { + // ignore + } + + // Determine which page to use (original or newly opened) + let targetPage = page; + if (result && (result as any).type === 'new') { + targetPage = (result as any).page; + await targetPage.waitForLoadState('domcontentloaded'); + } + + // If an auth code input is present on the active page, generate TOTP and fill it + if (await targetPage.$('#authcode')) { + const info = test.info(); + const use = (info.project && (info.project as any).use) || {}; + const secret = (use.adminTOTPSecret as string) || ''; + if (!secret) throw new Error('adminTOTPSecret not set in config'); + + // Generate TOTP inside the page using SubtleCrypto + const otp = await targetPage.evaluate(async (s) => { + function base32Decode(input) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + const cleaned = input.replace(/=+$/, '').toUpperCase().replace(/[^A-Z2-7]/g, ''); + let bits = ''; + for (const ch of cleaned) { + const val = alphabet.indexOf(ch); + bits += val.toString(2).padStart(5, '0'); + } + const bytes: number[] = []; + for (let i = 0; i + 8 <= bits.length; i += 8) { + bytes.push(parseInt(bits.substr(i, 8), 2)); + } + return new Uint8Array(bytes); + } + + /** + * Converts a given counter to a big-endian Uint8Array + * @param {number} counter - the counter to convert + * @returns {Uint8Array} a big-endian Uint8Array + */ + function toBigEndianUint8(counter) { + const buf = new ArrayBuffer(8); + const dv = new DataView(buf); + // split into hi/lo + const hi = Math.floor(counter / Math.pow(2, 32)); + const lo = counter >>> 0; + dv.setUint32(0, hi); + dv.setUint32(4, lo); + return new Uint8Array(buf); + } + const key = base32Decode(s); + const epoch = Math.floor(Date.now() / 1000); + const timestep = 30; + const counter = Math.floor(epoch / timestep); + const counterBytes = toBigEndianUint8(counter); + + const cryptoKey = await crypto.subtle.importKey('raw', key.buffer, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); + const sig = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes); + const hmac = new Uint8Array(sig); + const offset = hmac[hmac.length - 1] & 0xf; + const code = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff); + const otp = (code % 10 ** 6).toString().padStart(6, '0'); + return otp; + }, secret); + + await targetPage.fill('#authcode', otp); + } + if (!targetPage.isClosed()) await targetPage.waitForTimeout(2000); +}); From 725790aa326c8f9e62e530de093c20bf487fafe2 Mon Sep 17 00:00:00 2001 From: Sudip Dadhaniya <61381229+sudip-d@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:28:52 +0530 Subject: [PATCH 2/2] Format TypeScript files: Fix indentation and improve code structure - Fix inconsistent indentation in playwright.config.ts and admin-login-auth-app.spec.ts - Add proper TypeScript type annotations - Improve code readability and structure - Maintain consistent 2-space indentation throughout - Fix test name syntax error in admin-login-auth-app.spec.ts --- playwright.config.ts | 17 ++++++++--------- tests/admin-login-auth-app.spec.ts | 29 +++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 5647e027..7d66f97d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,6 +8,7 @@ declare module '@playwright/test' { adminTOTPSecret: string; } } + export default defineConfig({ testDir: './tests', /* Run tests in files in parallel */ @@ -22,14 +23,14 @@ export default defineConfig({ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - baseURL: 'http://localhost:8888/', + baseURL: 'http://localhost:8888/', trace: 'on-first-retry', - // Admin credentials for admin tests (override via env in CI if needed) - adminURL: 'http://localhost:8888/wp-admin/', - adminUser: 'admin', - adminPassword: 'password', - // Admin TOTP secret (base32) used by injectTOTP helper - adminTOTPSecret: 'MRMHA4LTLFMEMPBOMEQES7RQJQ2CCMZX', + // Admin credentials for admin tests (override via env in CI if needed) + adminURL: 'http://localhost:8888/wp-admin/', + adminUser: 'admin', + adminPassword: 'password', + // Admin TOTP secret (base32) used by injectTOTP helper + adminTOTPSecret: 'MRMHA4LTLFMEMPBOMEQES7RQJQ2CCMZX', }, /* Configure projects for major browsers */ @@ -38,12 +39,10 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, - { name: 'webkit', use: { ...devices['Desktop Safari'] }, diff --git a/tests/admin-login-auth-app.spec.ts b/tests/admin-login-auth-app.spec.ts index 71d811a8..e4233f7a 100644 --- a/tests/admin-login-auth-app.spec.ts +++ b/tests/admin-login-auth-app.spec.ts @@ -1,6 +1,6 @@ import { test } from '@playwright/test'; -test('Admin login)', async ({ page, context }) => { +test('Admin login', async ({ page, context }) => { const info = test.info(); const use = (info.project && (info.project as any).use) || {}; const ADMIN_URL = (use.adminURL as string) || (use.baseURL as string) || ''; @@ -25,7 +25,7 @@ test('Admin login)', async ({ page, context }) => { let result = null; try { - result = await Promise.race([newPagePromise, authPromise, navPromise]) as any; + result = await Promise.race([newPagePromise, authPromise, navPromise]) as any; } catch (e) { // ignore } @@ -46,7 +46,7 @@ test('Admin login)', async ({ page, context }) => { // Generate TOTP inside the page using SubtleCrypto const otp = await targetPage.evaluate(async (s) => { - function base32Decode(input) { + function base32Decode(input: string): Uint8Array { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const cleaned = input.replace(/=+$/, '').toUpperCase().replace(/[^A-Z2-7]/g, ''); let bits = ''; @@ -54,7 +54,7 @@ test('Admin login)', async ({ page, context }) => { const val = alphabet.indexOf(ch); bits += val.toString(2).padStart(5, '0'); } - const bytes: number[] = []; + const bytes: number[] = []; for (let i = 0; i + 8 <= bits.length; i += 8) { bytes.push(parseInt(bits.substr(i, 8), 2)); } @@ -66,7 +66,7 @@ test('Admin login)', async ({ page, context }) => { * @param {number} counter - the counter to convert * @returns {Uint8Array} a big-endian Uint8Array */ - function toBigEndianUint8(counter) { + function toBigEndianUint8(counter: number): Uint8Array { const buf = new ArrayBuffer(8); const dv = new DataView(buf); // split into hi/lo @@ -76,22 +76,35 @@ test('Admin login)', async ({ page, context }) => { dv.setUint32(4, lo); return new Uint8Array(buf); } + const key = base32Decode(s); const epoch = Math.floor(Date.now() / 1000); const timestep = 30; const counter = Math.floor(epoch / timestep); const counterBytes = toBigEndianUint8(counter); - const cryptoKey = await crypto.subtle.importKey('raw', key.buffer, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key.buffer, + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'] + ); const sig = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes); const hmac = new Uint8Array(sig); const offset = hmac[hmac.length - 1] & 0xf; - const code = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff); + const code = ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); const otp = (code % 10 ** 6).toString().padStart(6, '0'); return otp; }, secret); await targetPage.fill('#authcode', otp); } - if (!targetPage.isClosed()) await targetPage.waitForTimeout(2000); + + if (!targetPage.isClosed()) { + await targetPage.waitForTimeout(2000); + } });