Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 16s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Adds the admin fixture the Search Sources screen needed, and repairs the reason no browser-driving e2e test could log in at all. The sign-in failure first. The suite drove the app over http on a container hostname, which is not a secure context, so the browser provides no crypto.randomUUID. AccountBoundary calls it to mint a session generation on every sign-in; the call threw, the boot handler's catch swallowed it, and every test landed on the login screen holding a perfectly valid session. Measured: isSecureContext false and randomUUID undefined on http://pediatric-ai-scribe-e2e:3000, both true on http://127.0.0.1:3553, where boundary.enter() returns true and the app enters. Chrome's --unsafely-treat-insecure-origin-as-secure was tried first and does not work: Playwright rejects the --user-data-dir it must be paired with, and the flag alone leaves isSecureContext false. Loopback needs no flags, so the runner now uses the host network and the published port. The seed is new. The e2e user was a registration someone did by hand once that the shared Postgres happened to keep — enough to log in and no more. There was no admin account, so nothing under /api/admin could be tested through a real request, which is how the Search Sources card came to be verified by reading its markup. e2e/seed.js creates both accounts and reconciles an existing one, so a leftover with the wrong role cannot fail the suite for a reason unrelated to the code. It resets passwords and grants admin, so it refuses any address outside @ped-ai.test. The runner seeds before it tests. The new spec covers what markup-reading could not: that an ordinary account is refused the settings and never offered the Admin menu item, that no API key comes back readable, that the Test button reports each source separately, and that every control the save handler reads exists in a real render. Each account gets its own browser context, because AccountBoundary allows one owner per document and freezing the page on a second is the behaviour, not a bug. 10/10 pass on both projects. Two unit tests pin the loopback requirement and the seed's domain guard so neither can be undone quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
138 lines
6.7 KiB
JavaScript
138 lines
6.7 KiB
JavaScript
// ============================================================
|
|
// ADMIN — SEARCH SOURCES
|
|
// ============================================================
|
|
// The screen that decides whether a generated resource may search the web or
|
|
// PubMed, and holds the keys for both.
|
|
//
|
|
// This exists because that card could not previously be tested at all: there
|
|
// was no admin account to log in as, so it was checked by reading its markup
|
|
// and confirming the element ids matched the handlers. That verifies the wiring
|
|
// and nothing about whether an administrator can actually reach the screen,
|
|
// whether an ordinary user is kept off it, or whether a saved key survives a
|
|
// round trip.
|
|
//
|
|
// Two things are asserted that are easy to get wrong and expensive to get
|
|
// wrong: the routes are admin-only, and a key is never sent back to the browser
|
|
// in readable form.
|
|
// ============================================================
|
|
|
|
const { test, expect, E2E_BASE, TEST_EMAIL, ADMIN_EMAIL, loginAs, getAuthToken, getAdminToken } = require('../fixtures');
|
|
|
|
test.describe('Search Sources', () => {
|
|
let adminToken, userToken;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
adminToken = await getAdminToken(request);
|
|
userToken = await getAuthToken(request);
|
|
});
|
|
|
|
const auth = t => ({ Authorization: 'Bearer ' + t, 'Content-Type': 'application/json' });
|
|
|
|
test('an ordinary account cannot read or change search settings', async ({ request }) => {
|
|
const read = await request.get(E2E_BASE + '/api/admin/websearch', { headers: auth(userToken) });
|
|
expect(read.status(), 'a non-admin must not read the settings').toBeGreaterThanOrEqual(400);
|
|
|
|
const write = await request.put(E2E_BASE + '/api/admin/websearch', {
|
|
headers: auth(userToken), data: { enabled: 'true', provider: 'tavily' },
|
|
});
|
|
expect(write.status(), 'nor change them').toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
test('an administrator reads the settings, and no key comes back readable', async ({ request }) => {
|
|
const r = await request.get(E2E_BASE + '/api/admin/websearch', { headers: auth(adminToken) });
|
|
expect(r.ok(), await r.text()).toBeTruthy();
|
|
const config = (await r.json()).config;
|
|
expect(config, 'settings come back under config').toBeTruthy();
|
|
|
|
// Both sources are represented, so the screen has something to render.
|
|
// Bracketed, not toHaveProperty: these key names contain dots, and a dotted
|
|
// string is read as a path into the object rather than as one key.
|
|
for (const key of ['websearch.enabled', 'websearch.provider', 'pubmed.enabled']) {
|
|
expect(Object.keys(config), key + ' is missing').toContain(key);
|
|
}
|
|
|
|
// A key is either absent or masked. Anything else means a secret is being
|
|
// handed to the browser, which is the one failure here worth catching.
|
|
for (const key of ['websearch.api_key', 'pubmed.api_key']) {
|
|
const value = config[key];
|
|
if (value) expect(value, key + ' must be masked').toMatch(/^•+/);
|
|
}
|
|
});
|
|
|
|
test('the Test button reports each source separately', async ({ request }) => {
|
|
const r = await request.post(E2E_BASE + '/api/admin/websearch/test', {
|
|
headers: auth(adminToken), data: { query: 'bronchiolitis high flow' },
|
|
});
|
|
expect(r.ok(), await r.text()).toBeTruthy();
|
|
const body = await r.json();
|
|
|
|
// One press has to say which of the two works, so each reports either a
|
|
// count or a reason — never nothing at all.
|
|
for (const source of ['web', 'pubmed']) {
|
|
expect(body[source], source + ' must be reported').toBeTruthy();
|
|
const reported = typeof body[source].count === 'number' || Boolean(body[source].reason);
|
|
expect(reported, source + ' reported neither a count nor a reason').toBeTruthy();
|
|
}
|
|
});
|
|
|
|
// Admin is not a tab on the rail; it is an item in the account-card menu that
|
|
// is only created when the signed-in user has the admin role. So opening it
|
|
// and finding the menu item missing are the same assertion from both sides.
|
|
async function openAdmin(page) {
|
|
await page.locator('.account-card-btn').first().click();
|
|
await page.locator('[data-account-tab="admin"]').first().click();
|
|
await page.waitForFunction(() => {
|
|
const el = document.getElementById('admin-tab');
|
|
return el && el.classList.contains('active') && el.innerHTML.trim().length > 100;
|
|
}, { timeout: 20000 });
|
|
}
|
|
|
|
// AccountBoundary allows one verified owner per document and freezes the page
|
|
// rather than letting a second account in, so each account is checked in its
|
|
// own browser context. Swapping the cookie inside one context is not a
|
|
// shortcut here — it is the thing the app deliberately refuses.
|
|
async function pageFor(browser, email) {
|
|
const context = await browser.newContext();
|
|
await loginAs(context, context.request, email);
|
|
const page = await context.newPage();
|
|
await page.goto(E2E_BASE + '/');
|
|
await page.waitForSelector('button.tab-btn', { timeout: 20000 });
|
|
// At phone width the rail — and the account card with it — is behind the
|
|
// menu toggle, the same way the other specs open it.
|
|
const vp = page.viewportSize();
|
|
if (vp && vp.width <= 768) await page.click('#btn-menu-toggle').catch(() => {});
|
|
return { page, context };
|
|
}
|
|
|
|
test('the account menu offers Admin to an administrator only', async ({ browser }) => {
|
|
const mine = await pageFor(browser, TEST_EMAIL);
|
|
await mine.page.locator('.account-card-btn').first().click();
|
|
await expect(mine.page.locator('[data-account-tab="admin"]'),
|
|
'an ordinary account is never offered Admin').toHaveCount(0);
|
|
await mine.context.close();
|
|
|
|
const theirs = await pageFor(browser, ADMIN_EMAIL);
|
|
await theirs.page.locator('.account-card-btn').first().click();
|
|
await expect(theirs.page.locator('[data-account-tab="admin"]').first()).toBeVisible();
|
|
await theirs.context.close();
|
|
});
|
|
|
|
test('the card renders for an administrator, with both sources', async ({ browser }) => {
|
|
const { page: adminPage, context } = await pageFor(browser, ADMIN_EMAIL);
|
|
await openAdmin(adminPage);
|
|
await adminPage.waitForSelector('#ws-provider', { timeout: 20000 });
|
|
|
|
// Every control the save handler reads must exist, which is the failure the
|
|
// static id linter catches and this confirms in a real render.
|
|
for (const id of ['ws-enabled', 'ws-provider', 'ws-api-key', 'ws-base-url',
|
|
'pm-enabled', 'pm-api-key', 'pm-email', 'ws-status']) {
|
|
await expect(adminPage.locator('#' + id), '#' + id + ' is missing').toHaveCount(1);
|
|
}
|
|
|
|
// Anything typed into a key field must not be a readable input.
|
|
for (const id of ['ws-api-key', 'pm-api-key']) {
|
|
await expect(adminPage.locator('#' + id)).toHaveAttribute('type', 'password');
|
|
}
|
|
await context.close();
|
|
});
|
|
});
|