fixes and refactor
This commit is contained in:
69
e2e/global-setup.js
Normal file
69
e2e/global-setup.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// Global setup to seed admin user before Playwright tests
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
module.exports = async () => {
|
||||
const SECRET_KEY = process.env.SECRET_KEY || 'x'.repeat(32);
|
||||
const path = require('path');
|
||||
const dbPath = path.resolve(__dirname, '..', '.e2e-db.sqlite');
|
||||
const DATABASE_URL = process.env.DATABASE_URL || `sqlite:////${dbPath}`;
|
||||
|
||||
// Ensure a clean database for deterministic tests
|
||||
try { fs.rmSync(dbPath, { force: true }); } catch (_) {}
|
||||
|
||||
const pyCode = `
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.database.base import engine
|
||||
from app.models import BaseModel
|
||||
from app.models.user import User
|
||||
from app.auth.security import get_password_hash
|
||||
import os
|
||||
|
||||
# Ensure tables
|
||||
BaseModel.metadata.create_all(bind=engine)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
admin = db.query(User).filter(User.username=='admin').first()
|
||||
if not admin:
|
||||
admin = User(
|
||||
username=os.getenv('ADMIN_USERNAME','admin'),
|
||||
email=os.getenv('ADMIN_EMAIL','admin@delphicg.local'),
|
||||
full_name=os.getenv('ADMIN_FULLNAME','System Administrator'),
|
||||
hashed_password=get_password_hash(os.getenv('ADMIN_PASSWORD','admin123')),
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
print('Seeded admin user')
|
||||
else:
|
||||
print('Admin user already exists')
|
||||
finally:
|
||||
db.close()
|
||||
`;
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
SECRET_KEY,
|
||||
DATABASE_URL,
|
||||
ADMIN_EMAIL: 'admin@example.com',
|
||||
ADMIN_USERNAME: 'admin',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
|
||||
};
|
||||
let res = spawnSync('python3', ['-c', pyCode], { env, stdio: 'inherit' });
|
||||
if (res.error) {
|
||||
res = spawnSync('python', ['-c', pyCode], { env, stdio: 'inherit' });
|
||||
if (res.error) throw res.error;
|
||||
}
|
||||
|
||||
// Pre-generate a valid access token to bypass login DB writes in tests
|
||||
const token = jwt.sign({ sub: env.ADMIN_USERNAME, type: 'access' }, env.SECRET_KEY, { expiresIn: '4h' });
|
||||
// Persist to a file for the tests to read
|
||||
const tokenPath = path.resolve(__dirname, '..', '.e2e-token');
|
||||
fs.writeFileSync(tokenPath, token, 'utf-8');
|
||||
};
|
||||
|
||||
|
||||
239
e2e/search.e2e.spec.js
Normal file
239
e2e/search.e2e.spec.js
Normal file
@@ -0,0 +1,239 @@
|
||||
// Playwright E2E tests for Advanced Search UI
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function loginAndSetTokens(page) {
|
||||
// Read pre-generated access token
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const tokenPath = path.resolve(__dirname, '..', '.e2e-token');
|
||||
const access = fs.readFileSync(tokenPath, 'utf-8').trim();
|
||||
const refresh = '';
|
||||
await page.addInitScript((a, r) => {
|
||||
try { window.localStorage.setItem('auth_token', a); } catch (_) {}
|
||||
try { if (r) window.localStorage.setItem('refresh_token', r); } catch (_) {}
|
||||
}, access, refresh);
|
||||
return access;
|
||||
}
|
||||
|
||||
async function apiCreateCustomer(page, payload, token) {
|
||||
// Use import endpoint to avoid multiple writes and simplify schema
|
||||
const req = await page.request.post('/api/import/customers', {
|
||||
data: { customers: [payload] },
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
expect(req.ok()).toBeTruthy();
|
||||
// Return id directly
|
||||
return payload.id;
|
||||
}
|
||||
|
||||
async function apiCreateFile(page, payload, token) {
|
||||
const req = await page.request.post('/api/import/files', {
|
||||
data: { files: [payload] },
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
expect(req.ok()).toBeTruthy();
|
||||
return payload.file_no;
|
||||
}
|
||||
|
||||
test.describe('Advanced Search UI', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// no-op here; call per test to capture token
|
||||
});
|
||||
|
||||
test('returns highlighted results and enforces XSS safety', async ({ page }) => {
|
||||
const token = `E2E-${Date.now()}`;
|
||||
const accessToken = await loginAndSetTokens(page);
|
||||
const malicious = `${token} <img src=x onerror=alert(1)>`;
|
||||
await apiCreateCustomer(page, {
|
||||
id: `E2E-CUST-${Date.now()}`,
|
||||
first: 'Alice',
|
||||
last: malicious,
|
||||
email: `alice.${Date.now()}@example.com`,
|
||||
city: 'Austin',
|
||||
abrev: 'TX',
|
||||
}, accessToken);
|
||||
|
||||
await page.goto('/search');
|
||||
await page.fill('#searchQuery', token);
|
||||
await page.click('#advancedSearchForm button[type="submit"]');
|
||||
await page.waitForResponse(res => res.url().includes('/api/search/advanced') && res.request().method() === 'POST');
|
||||
|
||||
const results = page.locator('#searchResults .search-result-item');
|
||||
await expect(results.first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const matchHtml = page.locator('#searchResults .search-result-item .text-sm.text-info-600');
|
||||
if (await matchHtml.count()) {
|
||||
const html = await matchHtml.first().innerHTML();
|
||||
expect(html).toContain('<strong>');
|
||||
expect(html).not.toContain('onerror');
|
||||
expect(html).not.toContain('<script');
|
||||
}
|
||||
});
|
||||
|
||||
test('pagination works when results exceed page size', async ({ page }) => {
|
||||
const token = `E2E-PAGE-${Date.now()}`;
|
||||
const accessToken = await loginAndSetTokens(page);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const ownerId = await apiCreateCustomer(page, {
|
||||
id: `E2E-P-OWNER-${Date.now()}`,
|
||||
first: 'Bob',
|
||||
last: 'Pagination',
|
||||
email: `bob.${Date.now()}@example.com`,
|
||||
city: 'Austin',
|
||||
abrev: 'TX',
|
||||
}, accessToken);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await apiCreateFile(page, {
|
||||
file_no: `E2E-F-${Date.now()}-${i}`,
|
||||
id: ownerId,
|
||||
regarding: `About ${token} #${i}`,
|
||||
empl_num: 'E01',
|
||||
file_type: 'CIVIL',
|
||||
opened: today,
|
||||
status: 'ACTIVE',
|
||||
rate_per_hour: 150,
|
||||
memo: 'seeded',
|
||||
}, accessToken);
|
||||
}
|
||||
|
||||
await page.goto('/search');
|
||||
await page.fill('#searchQuery', token);
|
||||
await page.click('#advancedSearchForm button[type="submit"]');
|
||||
await page.waitForResponse(res => res.url().includes('/api/search/advanced') && res.request().method() === 'POST');
|
||||
|
||||
const pager = page.locator('#searchPagination');
|
||||
await expect(pager).toBeVisible({ timeout: 10000 });
|
||||
const firstPageActive = page.locator('#searchPagination button.bg-primary-600');
|
||||
await expect(firstPageActive).toContainText('1');
|
||||
|
||||
const next = page.locator('#searchPagination button', { hasText: 'Next' });
|
||||
await Promise.all([
|
||||
page.waitForResponse((res) => res.url().includes('/api/search/advanced') && res.request().method() === 'POST'),
|
||||
next.click(),
|
||||
]);
|
||||
const active = page.locator('#searchPagination button.bg-primary-600');
|
||||
await expect(active).not.toContainText('1');
|
||||
});
|
||||
|
||||
test('suggestions dropdown renders safely and clicking populates input and triggers search', async ({ page }) => {
|
||||
const token = `E2E-SUG-${Date.now()}`;
|
||||
await loginAndSetTokens(page);
|
||||
|
||||
const suggestionOne = `${token} first`;
|
||||
const suggestionTwo = `${token} second`;
|
||||
|
||||
// Stub the suggestions endpoint for our token
|
||||
await page.route('**/api/search/suggestions*', async (route) => {
|
||||
try {
|
||||
const url = new URL(route.request().url());
|
||||
const q = url.searchParams.get('q') || '';
|
||||
if (q.includes(token)) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
suggestions: [
|
||||
{ text: suggestionOne, category: 'customer', description: 'Name match' },
|
||||
{ text: suggestionTwo, category: 'file', description: 'File regarding' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
// Stub the advanced search to assert it gets triggered with clicked suggestion
|
||||
let receivedQuery = null;
|
||||
await page.route('**/api/search/advanced', async (route) => {
|
||||
try {
|
||||
const body = route.request().postDataJSON();
|
||||
receivedQuery = body?.query || null;
|
||||
} catch (_) {}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
total_results: 0,
|
||||
stats: { search_execution_time: 0.001 },
|
||||
facets: { customer: {}, file: {}, ledger: {}, qdro: {}, document: {}, phone: {} },
|
||||
results: [],
|
||||
page_info: { current_page: 1, total_pages: 0, has_previous: false, has_next: false },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/search');
|
||||
|
||||
// Type to trigger suggestions (debounced)
|
||||
await page.fill('#searchQuery', token);
|
||||
|
||||
const dropdown = page.locator('#searchSuggestions');
|
||||
const items = dropdown.locator('a');
|
||||
await expect(items).toHaveCount(2, { timeout: 5000 });
|
||||
await expect(dropdown).toBeVisible();
|
||||
|
||||
// Basic safety check — ensure no script tags ended up in suggestions markup
|
||||
const dropdownHtml = await dropdown.innerHTML();
|
||||
expect(dropdownHtml).not.toContain('<script');
|
||||
|
||||
// Click the first suggestion and expect a search to be performed with that query
|
||||
await Promise.all([
|
||||
page.waitForResponse((res) => res.url().includes('/api/search/advanced') && res.request().method() === 'POST'),
|
||||
items.first().click(),
|
||||
]);
|
||||
|
||||
await expect(page.locator('#searchQuery')).toHaveValue(new RegExp(`^${suggestionOne}`));
|
||||
expect(receivedQuery || '').toContain(suggestionOne);
|
||||
});
|
||||
|
||||
test('Escape hides suggestions dropdown without triggering a search', async ({ page }) => {
|
||||
const token = `E2E-ESC-${Date.now()}`;
|
||||
await loginAndSetTokens(page);
|
||||
|
||||
// Track whether advanced search is called
|
||||
let calledAdvanced = false;
|
||||
await page.route('**/api/search/advanced', async (route) => {
|
||||
calledAdvanced = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
total_results: 0,
|
||||
stats: { search_execution_time: 0.001 },
|
||||
facets: { customer: {}, file: {}, ledger: {}, qdro: {}, document: {}, phone: {} },
|
||||
results: [],
|
||||
page_info: { current_page: 1, total_pages: 0, has_previous: false, has_next: false },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Stub suggestions so they appear
|
||||
await page.route('**/api/search/suggestions*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
suggestions: [
|
||||
{ text: `${token} foo`, category: 'customer', description: '' },
|
||||
{ text: `${token} bar`, category: 'file', description: '' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/search');
|
||||
await page.fill('#searchQuery', token);
|
||||
|
||||
const dropdown = page.locator('#searchSuggestions');
|
||||
await expect(dropdown.locator('a')).toHaveCount(2, { timeout: 5000 });
|
||||
await expect(dropdown).toBeVisible();
|
||||
|
||||
// Press Escape: should hide dropdown and not trigger search
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dropdown).toHaveClass(/hidden/);
|
||||
expect(calledAdvanced).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user