The write-only security check in 08-pr-vision-admin.spec.ts asserted
page.content() does not contain 'sk-or-'. The password input for the
API key renders with placeholder="sk-or-…" (an ellipsis format hint
shown when the key is admin-managed). This placeholder contains the
literal string 'sk-or-' so the naive toContain assertion trips even
though value="" and no real key is in the page.
Fix: replace toContain('sk-or-') with toMatch(/sk-or-v1-[A-Za-z0-9]{20,}/).
A real OpenRouter key uses the format 'sk-or-v1-<20+ alnum chars>';
the placeholder ellipsis 'sk-or-…' never matches this pattern, so the
false-positive is eliminated. The assertion still catches any real key
accidentally rendered in server-side HTML.
The value="" assertion is unchanged and continues to guard the core
write-only invariant independently.
Agent-Session: fix-smoke-key-assertion-20260617
221 lines
9.1 KiB
TypeScript
221 lines
9.1 KiB
TypeScript
/**
|
|
* 08-pr-vision-admin.spec.ts
|
|
*
|
|
* What: Smoke tests for the PR Vision plugin admin surfaces.
|
|
* Covers the dashboard, Settings (API-key-manager card + write-only
|
|
* invariant), Costs, and Call Log sub-pages. Guards against PHP fatals,
|
|
* missing menu registration, and key leakage regressions.
|
|
*
|
|
* Who triggers it: Playwright chromium project (post-auth setup).
|
|
* Dependencies: wp-admin authenticated session (from global.setup.ts).
|
|
*
|
|
* SKIP-IF-ABSENT: When PR Vision is not installed/activated on this
|
|
* environment, each test detects the absent page and skips with a loud
|
|
* annotation - consistent with the 05/06 merge-early binding pattern.
|
|
*
|
|
* Admin menu slugs (sourced from includes/core/class-prv-admin-page.php
|
|
* and includes/core/class-prv-settings-page.php,
|
|
* includes/core/class-prv-costs-page.php,
|
|
* includes/core/class-prv-call-log-page.php):
|
|
* Dashboard -> admin.php?page=pr-vision (MENU_SLUG = 'pr-vision')
|
|
* Settings -> admin.php?page=pr-vision-settings (MENU_SLUG = 'pr-vision-settings')
|
|
* Costs -> admin.php?page=pr-vision-costs (MENU_SLUG = 'pr-vision-costs')
|
|
* Call Log -> admin.php?page=pr-vision-calls (MENU_SLUG = 'pr-vision-calls')
|
|
*
|
|
* Selectors sourced from includes/core/class-prv-admin-page.php:
|
|
* h1 "PR Vision - AI Visibility" - dashboard page heading
|
|
*
|
|
* Selectors sourced from includes/core/class-prv-key-manager-renderer.php:
|
|
* #prv-key-card - the API-key-manager card container
|
|
* #prv-key-source-badge - key source status indicator span
|
|
* #prv_api_key - password input (must render empty - write-only invariant)
|
|
*
|
|
* Write-only invariant (security):
|
|
* The stored OpenRouter key must NEVER appear in page source.
|
|
* #prv_api_key renders with value="" on every page load regardless of
|
|
* whether a key is stored. This spec asserts the empty value and asserts
|
|
* that no structurally valid key token (regex /sk-or-v1-[A-Za-z0-9]{20,}/)
|
|
* appears in the full body HTML. A regex is used instead of a naive
|
|
* toContain('sk-or-') to avoid a false-positive on the password input's
|
|
* placeholder="sk-or-…" attribute (the ellipsis is a format hint, not a
|
|
* token body, and won't match the regex).
|
|
*
|
|
* @see peptiderepo/pr-vision includes/core/class-prv-admin-page.php
|
|
* @see peptiderepo/pr-vision includes/core/class-prv-settings-page.php
|
|
* @see peptiderepo/pr-vision includes/core/class-prv-key-manager-renderer.php
|
|
*/
|
|
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
const ADMIN = '/wp-admin';
|
|
|
|
/**
|
|
* Detect the "page not registered" condition WordPress returns when a plugin
|
|
* menu slug is unknown. Returns true when the plugin is absent.
|
|
*/
|
|
function isPluginAbsent(body: string, url: string): boolean {
|
|
return (
|
|
body.includes('Invalid plugin page') ||
|
|
body.includes('do not have sufficient permissions') ||
|
|
body.includes('The page you are looking for') ||
|
|
( url.includes('/wp-admin/') && ! url.includes('page=pr-vision') )
|
|
);
|
|
}
|
|
|
|
/** Push a loud skip annotation and call test.skip(). */
|
|
function skipMissing(): void {
|
|
test.info().annotations.push({
|
|
type: 'skip',
|
|
description:
|
|
'[pr-vision smoke] PR Vision plugin is not installed or activated ' +
|
|
'on this environment. Skipping per the merge-early binding.',
|
|
});
|
|
test.skip();
|
|
}
|
|
|
|
test.describe('WP Admin - PR Vision dashboard', () => {
|
|
test('dashboard page loads (HTTP 200, no PHP fatal)', async ({ page }) => {
|
|
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision`);
|
|
expect(res?.status()).toBe(200);
|
|
|
|
await expect(page.locator('#wpbody')).toBeVisible();
|
|
|
|
const body = (await page.textContent('body')) ?? '';
|
|
expect(body).not.toContain('Fatal error');
|
|
|
|
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
|
|
|
// Heading from class-prv-admin-page.php render_page():
|
|
// echo '<h1>' . esc_html__( 'PR Vision - AI Visibility', 'pr-vision' ) . '</h1>';
|
|
await expect(
|
|
page.locator('h1').filter({ hasText: 'PR Vision' })
|
|
).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
});
|
|
|
|
test.describe('WP Admin - PR Vision Settings page (API-key-manager)', () => {
|
|
test('settings page loads (HTTP 200, no PHP fatal)', async ({ page }) => {
|
|
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision-settings`);
|
|
expect(res?.status()).toBe(200);
|
|
|
|
await expect(page.locator('#wpbody')).toBeVisible();
|
|
|
|
const body = (await page.textContent('body')) ?? '';
|
|
expect(body).not.toContain('Fatal error');
|
|
|
|
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
|
});
|
|
|
|
test('API-key-manager card is present and status indicator visible', async ({ page }) => {
|
|
await page.goto(`${ADMIN}/admin.php?page=pr-vision-settings`);
|
|
|
|
await expect(page.locator('#wpbody')).toBeVisible();
|
|
const body = (await page.textContent('body')) ?? '';
|
|
expect(body).not.toContain('Fatal error');
|
|
|
|
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
|
|
|
// ── SKIP-IF-ABSENT guard: Settings page may not have key-card if Settings
|
|
// subpage (v0.2.0) is not yet deployed on this environment.
|
|
const keyCard = page.locator('#prv-key-card');
|
|
const cardPresent = await keyCard.count() > 0;
|
|
|
|
if ( ! cardPresent ) {
|
|
test.info().annotations.push({
|
|
type: 'skip',
|
|
description:
|
|
'[pr-vision smoke] #prv-key-card absent: PR Vision Settings page ' +
|
|
'(v0.2.0 admin) is not deployed on this environment. Skipping key-manager assertions.',
|
|
});
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// API-key-manager card container from class-prv-key-manager-renderer.php:
|
|
// <div class="prv-card" id="prv-key-card">
|
|
await expect(keyCard).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Key source status indicator: <span ... id="prv-key-source-badge">
|
|
// Shows one of: "Set via wp-config", "Set via admin", "Not set"
|
|
await expect(page.locator('#prv-key-source-badge')).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test('password input is present and renders EMPTY (write-only invariant)', async ({ page }) => {
|
|
await page.goto(`${ADMIN}/admin.php?page=pr-vision-settings`);
|
|
|
|
await expect(page.locator('#wpbody')).toBeVisible();
|
|
const body = (await page.textContent('body')) ?? '';
|
|
expect(body).not.toContain('Fatal error');
|
|
|
|
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
|
|
|
const keyCard = page.locator('#prv-key-card');
|
|
const cardPresent = await keyCard.count() > 0;
|
|
if ( ! cardPresent ) {
|
|
test.info().annotations.push({
|
|
type: 'skip',
|
|
description:
|
|
'[pr-vision smoke] #prv-key-card absent: skipping write-only invariant check.',
|
|
});
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Password input from class-prv-key-manager-renderer.php render_key_form():
|
|
// <input type="password" id="prv_api_key" name="prv_api_key" ... value="" ...>
|
|
// The renderer explicitly passes value="" -- the stored key is NEVER output.
|
|
const keyInput = page.locator('#prv_api_key');
|
|
|
|
// When the key source is SOURCE_CONSTANT the input is disabled but still rendered.
|
|
// In all cases it must be present in the DOM.
|
|
await expect(keyInput).toBeAttached({ timeout: 10_000 });
|
|
|
|
// Write-only invariant: value attribute must be empty string, not the stored key.
|
|
const inputValue = await keyInput.getAttribute('value');
|
|
expect(inputValue ?? '').toBe('');
|
|
|
|
// Security check: a *real* OpenRouter key (format "sk-or-v1-<20+ alnum chars>")
|
|
// must not appear anywhere in the page HTML -- guards against accidental
|
|
// server-side leakage.
|
|
//
|
|
// NOTE: We use a regex rather than the naive toContain('sk-or-') to avoid
|
|
// a false-positive: the password input carries placeholder="sk-or-…"
|
|
// (an ellipsis format-hint, not a token body) which contains the literal
|
|
// string "sk-or-" but is NOT a leaked key. A regex that requires the
|
|
// "v1-" segment and at least 20 alphanumeric characters only matches a
|
|
// structurally valid key, so the placeholder never trips it.
|
|
const html = await page.content();
|
|
expect(html).not.toMatch(/sk-or-v1-[A-Za-z0-9]{20,}/);
|
|
});
|
|
});
|
|
|
|
test.describe('WP Admin - PR Vision Costs page', () => {
|
|
test('costs page loads without fatal error', async ({ page }) => {
|
|
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision-costs`);
|
|
expect(res?.status()).toBe(200);
|
|
|
|
await expect(page.locator('#wpbody')).toBeVisible();
|
|
const body = (await page.textContent('body')) ?? '';
|
|
expect(body).not.toContain('Fatal error');
|
|
|
|
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
|
|
|
expect(body).not.toContain('Invalid plugin page');
|
|
});
|
|
});
|
|
|
|
test.describe('WP Admin - PR Vision Call Log page', () => {
|
|
test('call log page loads without fatal error', async ({ page }) => {
|
|
const res = await page.goto(`${ADMIN}/admin.php?page=pr-vision-calls`);
|
|
expect(res?.status()).toBe(200);
|
|
|
|
await expect(page.locator('#wpbody')).toBeVisible();
|
|
const body = (await page.textContent('body')) ?? '';
|
|
expect(body).not.toContain('Fatal error');
|
|
|
|
if (isPluginAbsent(body, page.url())) { skipMissing(); return; }
|
|
|
|
expect(body).not.toContain('Invalid plugin page');
|
|
});
|
|
});
|