Files
n8n/packages/@n8n/instance-ai/evaluations/__tests__/fixture-server.test.ts
T

788 lines
33 KiB
TypeScript

// Fixture-server tests. The browser-driven case is the one that matters: it
// drives the lookalike page the way the agent does (navigate, click, type,
// read the accessibility tree) through the same host-mapping + self-signed-cert
// setup the eval browser uses. Without it, "the fixture serves as the real
// hostname" is an untested assumption.
import { jsonParse } from 'n8n-workflow';
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { chromium, type BrowserContext } from 'playwright-core';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { findChromiumForEval, fixtureInterceptionArgs } from '../harness/browser-runtime';
import {
providerFixtureManifestSchema,
findFixtureForCredentialType,
loadProviderFixtures,
matchRoute,
MINT_PATH,
mintSecret,
startFixtureServer,
type FixtureServer,
} from '../harness/fixture-server';
import { createLogger } from '../harness/logger';
const logger = createLogger(false);
/**
* Boot a fixture server + a browser aimed at it, and register the teardown.
*
* Uses `fixtureInterceptionArgs` — the same builder `startBrowserRuntime` uses —
* so these tests pin the interception setup the eval browser really gets,
* including the loopback EXCLUDE rules. Hand-assembled args drifted from it.
*/
function fixtureBrowser(credentialType: string) {
const handle: { server?: FixtureServer; ctx?: BrowserContext } = {};
let userDataDir: string | undefined;
beforeAll(async () => {
const fixture = await findFixtureForCredentialType(credentialType);
if (!fixture) throw new Error(`${credentialType} fixture missing`);
const server = await startFixtureServer({ fixture, logger });
handle.server = server;
userDataDir = await mkdtemp(join(tmpdir(), `fixture-test-${fixture.id}-`));
handle.ctx = await chromium.launchPersistentContext(userDataDir, {
executablePath: findChromiumForEval(),
headless: true,
args: fixtureInterceptionArgs(server.hostResolverRules(), undefined),
});
}, 60_000);
afterAll(async () => {
await handle.ctx?.close().catch(() => {});
await handle.server?.close();
if (userDataDir) await rm(userDataDir, { recursive: true, force: true });
});
// Non-null at use: every `it` runs after beforeAll, and a failed boot throws
// there rather than handing back a half-built handle.
return handle as { server: FixtureServer; ctx: BrowserContext };
}
describe('provider fixtures', () => {
// One row per shipped fixture. A hardcoded table still reds on a missing
// fixture, because findFixtureForCredentialType returns undefined for a row
// that has no directory.
const SHIPPED = [
{
type: 'anthropicApi',
id: 'anthropic',
host: 'platform.claude.com',
prefix: 'sk-ant-api03-',
verify: { path: '/v1/models', header: 'x-api-key' },
},
{
type: 'openAiApi',
id: 'openai',
host: 'platform.openai.com',
prefix: 'sk-proj-',
verify: { path: '/models', header: 'Authorization', scheme: 'Bearer' },
},
{
type: 'googlePalmApi',
id: 'gemini',
host: 'aistudio.google.com',
// Google's current shape, not the legacy `AIza…` one.
prefix: 'AQ.',
// Base URL lives in `host`, and the key travels as a query parameter.
urlField: 'host',
verify: { path: '/v1beta/models', query: 'key' },
},
// No `verify`: slackApi's test hardcodes its baseURL, so the stand-in can
// never be aimed at it. The value check reports itself unverifiable.
{ type: 'slackApi', id: 'slack', host: 'api.slack.com', prefix: 'xoxb-' },
];
it.each(SHIPPED)('ships the $id fixture keyed on $type', async (row) => {
const fixture = await findFixtureForCredentialType(row.type);
expect(fixture?.id).toBe(row.id);
expect(fixture?.manifest.hosts).toContain(row.host);
expect(fixture?.manifest.secretPrefix).toBe(row.prefix);
expect(fixture?.manifest.urlField).toBe(row.urlField ?? 'url');
expect(fixture?.manifest.verify).toEqual(row.verify);
});
// n8n serializes every credential type's own test request, so the manifest's
// transcription of it can be CHECKED rather than trusted. Without this the
// table above only pins what someone typed on the day, and an upstream change
// to `test.request` silently downgrades the value check to "unverifiable".
const TYPE_FILES = [
join(__dirname, '..', '..', '..', '..', 'nodes-base', 'dist', 'types', 'credentials.json'),
join(__dirname, '..', '..', '..', 'nodes-langchain', 'dist', 'types', 'credentials.json'),
];
it.skipIf(!TYPE_FILES.some(existsSync))(
"matches each credential's own test.request (needs built dist/types)",
async () => {
const types = new Map<string, { baseURL?: string; url?: string }>();
for (const file of TYPE_FILES.filter(existsSync)) {
for (const cred of jsonParse<
Array<{
name: string;
test?: { request?: { baseURL?: string; url?: string } };
}>
>(await readFile(file, 'utf8'))) {
if (cred.test?.request) types.set(cred.name, cred.test.request);
}
}
const fixtures = await loadProviderFixtures();
let checked = 0;
for (const fixture of fixtures) {
const request = types.get(fixture.manifest.credentialType);
if (!request?.baseURL) continue;
checked += 1;
const interpolated = /\{\{\s*\$credentials\??\.(\w+)\s*\}\}/.exec(request.baseURL);
if (!interpolated) {
// Hardcoded baseURL: there is no field to substitute, so a `verify`
// block could never be aimed at the stand-in.
expect(fixture.manifest.verify, `${fixture.id} verify`).toBeUndefined();
continue;
}
expect(fixture.manifest.urlField, `${fixture.id} urlField`).toBe(interpolated[1]);
const suffix = request.baseURL.slice(interpolated.index + interpolated[0].length);
expect(fixture.manifest.verify?.path, `${fixture.id} verify.path`).toBe(
`${suffix}${request.url ?? ''}`,
);
}
// Every shipped fixture must have been compared. Without this, a rename
// or a change to the serialized shape makes every iteration `continue`
// and the check passes having verified nothing.
expect(checked, 'fixtures cross-checked against their credential type').toBe(fixtures.length);
},
);
// The pages hardcode this path (they cannot import TS), and a rename would
// make every fixture's create button silently do nothing: the POST would fall
// through to the default page, which answers 200 with HTML, so `response.json()`
// throws inside the page and the run reds as agent misbehaviour.
it('serves the mint path every fixture page posts to', async () => {
for (const fixture of await loadProviderFixtures()) {
for (const file of Object.values(fixture.manifest.routes)) {
const html = await readFile(join(fixture.dir, file), 'utf8');
if (html.includes('__fixture__')) expect(html).toContain(MINT_PATH);
}
}
});
it('returns nothing for a credential type no fixture covers', async () => {
expect(await findFixtureForCredentialType('notionApi')).toBeUndefined();
});
it('gives every fixture a credential type of its own', async () => {
// Selection is BY credential type, so a duplicate would depend on readdir order.
const types = (await loadProviderFixtures()).map((f) => f.manifest.credentialType);
expect(types).toEqual([...new Set(types)]);
});
it('declares a defaultRoute that is one of its own routes', async () => {
for (const fixture of await loadProviderFixtures()) {
expect(Object.keys(fixture.manifest.routes)).toContain(fixture.manifest.defaultRoute);
}
});
});
describe('matchRoute', () => {
const routes = ['/apps', '/apps/:appId/general', '/apps/:appId/install-on-team'];
it('resolves a parameter segment to any value', () => {
expect(matchRoute(routes, '/apps/A0BQ7AWFNHM/general')).toBe('/apps/:appId/general');
expect(matchRoute(routes, '/apps/A0EVALAPP/install-on-team')).toBe(
'/apps/:appId/install-on-team',
);
});
it('prefers an exact route over a pattern that would also match', () => {
expect(matchRoute(['/apps/:appId/general', '/apps/fixed/general'], '/apps/fixed/general')).toBe(
'/apps/fixed/general',
);
});
it('does not match across segment boundaries', () => {
// Without this a console's whole URL space collapses onto one page, and the
// agent gets a plausible-looking wrong page instead of the default.
expect(matchRoute(routes, '/apps/A0BQ7AWFNHM/general/extra')).toBeUndefined();
expect(matchRoute(routes, '/apps/A0BQ7AWFNHM')).toBeUndefined();
expect(matchRoute(routes, '/apps/A0BQ7AWFNHM/oauth')).toBeUndefined();
});
it('still resolves plain routes', () => {
expect(matchRoute(routes, '/apps')).toBe('/apps');
expect(matchRoute(routes, '/nope')).toBeUndefined();
});
});
describe('mintSecret', () => {
it('carries the provider prefix and never repeats', () => {
const a = mintSecret('sk-ant-api03-');
const b = mintSecret('sk-ant-api03-');
expect(a.startsWith('sk-ant-api03-')).toBe(true);
expect(a).not.toBe(b);
expect(a.length).toBeGreaterThan(30);
});
});
describe('fixture server served to a real browser', () => {
const fx = fixtureBrowser('anthropicApi');
it('answers as the real hostname, so the agent never sees loopback', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.claude.com/settings/keys');
expect(page.url()).toBe('https://platform.claude.com/settings/keys');
expect(page.url()).not.toContain('127.0.0.1');
expect(await page.locator('h1').textContent()).toContain('API keys');
await page.close();
});
it('serves the default page for an unmodelled path instead of stranding the agent', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.claude.com/some/unmodelled/path');
expect(await page.locator('h1').textContent()).toBe('Dashboard');
await page.close();
});
it('does not expose the secret before the agent creates a key', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.claude.com/settings/keys');
expect(await page.content()).not.toContain(fx.server.mintedSecret);
expect(fx.server.secretWasIssued).toBe(false);
await page.close();
});
it('hands out exactly the ledger secret through the create-key flow', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.claude.com/dashboard');
// Navigate the way the agent has to: the landing page is not the key page.
// Scoped to the nav because the page links to it twice — which is true of
// real consoles too, and harmless for the agent (it clicks unique refs).
await page.getByRole('navigation').getByRole('link', { name: 'API keys' }).click();
expect(await page.locator('h1').textContent()).toContain('API keys');
await page.getByRole('button', { name: 'Create Key' }).click();
await page.getByLabel('Name', { exact: true }).fill('n8n');
// Submit is "Add" on the real console — "Create Key" only opens the dialog.
const add = page.getByRole('dialog').getByRole('button', { name: 'Add' });
// The real console disables Add until Expires is set (NODE-5755): a name
// alone is not enough, and clicking anyway does nothing.
expect(await add.isDisabled()).toBe(true);
// Click-driven, as in the real trace: this is a listbox, not a native select.
await page.getByRole('combobox', { name: 'Expires' }).click();
await page.getByRole('option', { name: '30 days' }).click();
expect(await add.isDisabled()).toBe(false);
await add.click();
// String body, not a closure: this program has no DOM lib, and the callback
// runs in the page anyway.
await page.waitForFunction("document.getElementById('key-value')?.value !== ''");
expect(await page.getByLabel('API key', { exact: true }).inputValue()).toBe(
fx.server.mintedSecret,
);
expect(fx.server.secretWasIssued).toBe(true);
// Event log, asserted here rather than in its own test so it can't pass on
// a previous test's side effects.
expect(fx.server.events.map((e) => e.path)).toContain('/settings/keys');
expect(fx.server.events.some((e) => e.mintedSecret)).toBe(true);
// The point of this assertion is that the agent reached us AS the provider,
// never as loopback. It is no longer "every event" because the wildcard
// catch-all now also routes unlisted hosts here — deliberately, so a
// fixture run cannot escape to the real internet.
expect(fx.server.events.some((e) => e.host === 'platform.claude.com')).toBe(true);
expect(fx.server.events.every((e) => e.host !== '127.0.0.1' && e.host !== 'localhost')).toBe(
true,
);
await page.close();
});
it('surfaces the disabled submit AS disabled, so the gap is diagnosable', async () => {
// Case 593 turns on this: the dead end has to be visible in the tree the
// agent reads, or the gap is undiagnosable rather than just open.
const page = await fx.ctx.newPage();
await page.goto('https://platform.claude.com/settings/keys');
await page.getByRole('button', { name: 'Create Key' }).click();
const outline = await page.getByRole('dialog').ariaSnapshot();
expect(outline).toMatch(/button "Add".*disabled/);
await page.close();
});
it('exposes an accessibility outline with the landmarks the agent needs', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.claude.com/settings/keys');
const outline = await page.locator('main').ariaSnapshot();
expect(outline).toContain('button "Create Key"');
expect(outline).toContain('heading "API keys');
// Calibrated against the real capture: sortable columns + truncated keys.
for (const col of ['Key', 'Workspace', 'Created by', 'Expires', 'Last used', 'Actions']) {
expect(outline).toContain(col);
}
expect(outline).toMatch(/sk-ant-api03-\S+\.\.\.\S+/);
await page.close();
});
});
// Same contract as the anthropic block, against the other console. Not shared
// with it: the differences between the consoles are the point.
describe('openai fixture served to a real browser', () => {
const fx = fixtureBrowser('openAiApi');
it('answers as the real hostname, so the agent never sees loopback', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/api-keys');
expect(page.url()).toBe('https://platform.openai.com/api-keys');
expect(await page.locator('h1').textContent()).toBe('API keys');
await page.close();
});
it('keeps the two "create...key" buttons distinguishable', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/api-keys');
// Closed, the submit is not in the tree at all — only the opener is.
expect(await page.getByRole('button', { name: 'Create' }).count()).toBe(1);
await page.getByRole('button', { name: 'Create new secret key' }).click();
// Open, a loose match hits BOTH: that is the hazard, and the exact names
// have to keep them apart.
expect(await page.getByRole('button', { name: 'Create' }).count()).toBe(2);
expect(await page.getByRole('button', { name: 'Create new secret key' }).count()).toBe(1);
expect(await page.getByRole('button', { name: 'Create secret key', exact: true }).count()).toBe(
1,
);
await page.close();
});
it('hands out exactly the ledger secret through the create-key flow', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/home');
// Navigate the way the agent has to: the landing page is not the key page.
await page.getByRole('complementary').getByRole('link', { name: 'API Keys' }).click();
expect(await page.locator('h1').textContent()).toBe('API keys');
await page.getByRole('button', { name: 'Create new secret key' }).click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Create new secret key' });
// Name is optional here — the contrast with anthropic's gated Add.
expect(await dialog.getByRole('button', { name: 'Create secret key' }).isDisabled()).toBe(
false,
);
await page.getByPlaceholder('My Test Key').fill('n8n');
await dialog.getByRole('button', { name: 'Create secret key' }).click();
await page.waitForFunction("document.getElementById('key-value')?.value !== ''");
const reveal = page.getByRole('dialog').filter({ hasText: 'Save your key' });
expect(await reveal.getByRole('textbox').inputValue()).toBe(fx.server.mintedSecret);
expect(fx.server.secretWasIssued).toBe(true);
expect(fx.server.events.some((e) => e.mintedSecret)).toBe(true);
expect(fx.server.events.some((e) => e.host === 'platform.openai.com')).toBe(true);
expect(fx.server.events.every((e) => e.host !== '127.0.0.1' && e.host !== 'localhost')).toBe(
true,
);
await page.close();
});
it('shows the new key TRUNCATED in the list, never in full', async () => {
// Creates the key on THIS page: the assertion is about the row the create
// flow renders, and a fresh page never contains the secret in the first
// place, so asserting there proves nothing.
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/api-keys');
await page.getByRole('button', { name: 'Create new secret key' }).click();
await page
.getByRole('dialog')
.getByRole('button', { name: 'Create secret key', exact: true })
.click();
await page.waitForFunction("document.getElementById('key-value')?.value !== ''");
const outline = await page.locator('table').ariaSnapshot();
expect(outline).toMatch(/sk-\.\.\.\w{4}/);
expect(outline).not.toContain(fx.server.mintedSecret);
// Nor may a derived column smuggle a run of the key into the tree.
expect(outline).not.toContain(fx.server.mintedSecret.slice(-16));
await page.close();
});
it('renders a name containing markup as text, not as markup', async () => {
// The name is free-form agent input. Interpolated into the row's HTML it
// would corrupt the table the truncation assertions read.
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/api-keys');
await page.getByRole('button', { name: 'Create new secret key' }).click();
await page.getByPlaceholder('My Test Key').fill('<b>bold</b> & co');
await page
.getByRole('dialog')
.getByRole('button', { name: 'Create secret key', exact: true })
.click();
await page.waitForFunction("document.getElementById('key-value')?.value !== ''");
expect(await page.locator('#key-rows tr').first().textContent()).toContain('<b>bold</b> & co');
expect(await page.locator('#key-rows b').count()).toBe(0);
await page.close();
});
it('exposes an accessibility outline with the landmarks the agent needs', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://platform.openai.com/api-keys');
const outline = await page.locator('main').ariaSnapshot();
expect(outline).toContain('button "Create new secret key"');
expect(outline).toContain('heading "API keys"');
// Calibrated against the real capture.
for (const col of ['Name', 'Status', 'Tracking ID', 'Secret Key', 'Created', 'Permissions']) {
expect(outline).toContain(col);
}
await page.close();
});
});
// The awkward console: two blocking overlays, no role=dialog, key as text.
describe('gemini fixture served to a real browser', () => {
const fx = fixtureBrowser('googlePalmApi');
it('blocks the page behind BOTH overlays until each is dismissed', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://aistudio.google.com/apikey');
expect(await page.getByRole('button', { name: 'Create API key' }).isVisible()).toBe(false);
await page.getByRole('button', { name: 'Close guided tour' }).click();
expect(await page.getByRole('button', { name: 'Create API key' }).isVisible()).toBe(false);
await page.getByRole('button', { name: 'OK, got it' }).click();
expect(await page.getByRole('button', { name: 'Create API key' }).isVisible()).toBe(true);
await page.close();
});
async function openConsole() {
const page = await fx.ctx.newPage();
await page.goto('https://aistudio.google.com/apikey');
await page.getByRole('button', { name: 'Close guided tour' }).click();
await page.getByRole('button', { name: 'OK, got it' }).click();
return page;
}
it('hands out exactly the ledger secret, as TEXT rather than a field', async () => {
const page = await openConsole();
await page.getByRole('button', { name: 'Create API key' }).click();
await page.getByLabel('Name your key').fill('n8n');
await page.getByRole('button', { name: 'Create key', exact: true }).click();
await page.waitForFunction("document.getElementById('key-value')?.textContent !== ''");
// textContent, not inputValue — the key is a text node here.
expect(await page.locator('#key-value').textContent()).toBe(fx.server.mintedSecret);
expect(fx.server.mintedSecret.startsWith('AQ.')).toBe(true);
expect(fx.server.secretWasIssued).toBe(true);
expect(fx.server.events.some((e) => e.host === 'aistudio.google.com')).toBe(true);
await page.close();
});
it('keeps the reveal panel findable even though it is not a role=dialog', async () => {
const page = await openConsole();
expect(await page.getByRole('dialog').count()).toBe(0);
await page.getByRole('button', { name: 'Create API key' }).click();
expect(await page.getByRole('dialog').count()).toBe(0);
expect(await page.getByLabel('Create a new key Close dialog').count()).toBe(1);
await page.close();
});
it('renders a name containing markup as text, not as markup', async () => {
const page = await openConsole();
await page.getByRole('button', { name: 'Create API key' }).click();
await page.getByLabel('Name your key').fill('<i>italic</i> & co');
await page.getByRole('button', { name: 'Create key', exact: true }).click();
await page.waitForFunction("document.getElementById('key-value')?.textContent !== ''");
expect(await page.locator('#key-rows tr').first().textContent()).toContain(
'<i>italic</i> & co',
);
expect(await page.locator('#key-rows i').count()).toBe(0);
await page.close();
});
it('shows only the last four characters in the list', async () => {
// Mint on this page first — see the openai equivalent.
const page = await openConsole();
await page.getByRole('button', { name: 'Create API key' }).click();
await page.getByLabel('Name your key').fill('n8n');
await page.getByRole('button', { name: 'Create key', exact: true }).click();
await page.waitForFunction("document.getElementById('key-value')?.textContent !== ''");
const outline = await page.locator('table').ariaSnapshot();
expect(outline).toMatch(/button "\.\.\.\w{4}"/);
expect(outline).not.toContain(fx.server.mintedSecret);
await page.close();
});
});
// Multi-page, unlike the other three: the token only exists after installing.
describe('slack fixture served to a real browser', () => {
const fx = fixtureBrowser('slackApi');
it('keeps the manifest editor click-trapped but typeable', async () => {
// The live reproduction for case 599: the focusable input sits under the
// rendered surface, so a click never passes the hit-test while fill()
// does. Short timeout here — the real one costs 30s.
const page = await fx.ctx.newPage();
await page.goto('https://api.slack.com/apps');
await page.getByRole('button', { name: 'Create New App' }).click();
await page.getByRole('button', { name: 'From a manifest' }).click();
await expect(page.locator('#manifest-input').click({ timeout: 1_000 })).rejects.toThrow(
/Timeout|intercepts pointer events/,
);
await page.locator('#manifest-input').fill('{"display_information":{"name":"n8n"}}');
expect(await page.locator('#manifest-input').inputValue()).toContain('display_information');
await page.close();
});
it('mangles a manifest entered key by key, and Next refuses it', async () => {
// The other half of the editor trap: auto-close fires per keystroke, so the
// manifest's own closers pile up and the result is no longer JSON. Guards the
// keydown handler — without it the box is a plain textarea and nothing traps.
const page = await fx.ctx.newPage();
await page.goto('https://api.slack.com/apps');
await page.getByRole('button', { name: 'Create New App' }).click();
await page.getByRole('button', { name: 'From a manifest' }).click();
const manifest = '{\n "display_information": {\n "name": "n8n"\n }\n}';
await page.locator('#manifest-input').pressSequentially(manifest);
expect(await page.locator('#manifest-input').inputValue()).not.toBe(manifest);
await page.getByRole('button', { name: 'Next' }).click();
expect(await page.locator('#manifest-error').isVisible()).toBe(true);
expect(new URL(page.url()).pathname).toBe('/apps');
await page.close();
});
it('accepts the same manifest inserted in one operation', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://api.slack.com/apps');
await page.getByRole('button', { name: 'Create New App' }).click();
await page.getByRole('button', { name: 'From a manifest' }).click();
const manifest = '{\n "display_information": {\n "name": "n8n"\n }\n}';
await page.locator('#manifest-input').fill(manifest);
expect(await page.locator('#manifest-input').inputValue()).toBe(manifest);
await page.getByRole('button', { name: 'Next' }).click();
await page.waitForURL(/\/apps\/A0\w+\/general/);
await page.close();
});
it('leaves Next enabled — nothing here is gated on a required field', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://api.slack.com/apps');
await page.getByRole('button', { name: 'Create New App' }).click();
await page.getByRole('button', { name: 'From a manifest' }).click();
expect(await page.getByRole('button', { name: 'Next' }).isDisabled()).toBe(false);
await page.close();
});
it('issues the ledger token only after Install to Workspace', async () => {
const page = await fx.ctx.newPage();
// An app id this fixture has never seen — the console mints one per app, and
// the agent navigates by typing those URLs back.
await page.goto('https://api.slack.com/apps/A0Q3ZK71LMN/install-on-team');
expect(await page.content()).not.toContain(fx.server.mintedSecret);
await page.getByRole('button', { name: 'Install to Workspace' }).click();
await page.waitForFunction("document.getElementById('bot-token')?.value !== ''");
expect(await page.getByLabel('Bot User OAuth Token').inputValue()).toBe(fx.server.mintedSecret);
expect(fx.server.mintedSecret.startsWith('xoxb-')).toBe(true);
expect(fx.server.secretWasIssued).toBe(true);
await page.close();
});
it('reaches the token only through the multi-page flow', async () => {
const page = await fx.ctx.newPage();
await page.goto('https://api.slack.com/apps/A0Q3ZK71LMN/general');
// Basic Information carries the OAuth2 credentials, never the bot token.
expect(await page.content()).not.toContain(fx.server.mintedSecret);
expect(await page.getByLabel('Client ID').inputValue()).toContain('.');
await page.getByRole('link', { name: 'Install App' }).click();
expect(await page.locator('h1').first().textContent()).toContain('OAuth Tokens');
// The id survives the hop rather than reverting to a hardcoded one.
expect(page.url()).toContain('/apps/A0Q3ZK71LMN/install-on-team');
await page.close();
});
});
describe('providerFixtureManifestSchema', () => {
const valid = {
credentialType: 'anthropicApi',
hosts: ['console.anthropic.com'],
secretPrefix: 'sk-ant-api03-',
routes: { '/settings/keys': 'console.html' },
defaultRoute: '/settings/keys',
};
it('accepts a minimal manifest, and verify is optional', () => {
expect(providerFixtureManifestSchema.safeParse(valid).success).toBe(true);
expect(
providerFixtureManifestSchema.safeParse({
...valid,
verify: { path: '/v1/models', header: 'x-api-key' },
}).success,
).toBe(true);
});
it('rejects a defaultRoute that is not one of the routes', () => {
// Used to throw at boot AFTER the TLS cert had been generated.
const res = providerFixtureManifestSchema.safeParse({ ...valid, defaultRoute: '/nope' });
expect(res.success).toBe(false);
if (!res.success) expect(res.error.issues[0].message).toContain('not one of routes');
});
it('rejects an unknown key, so a typo is not silently ignored', () => {
const res = providerFixtureManifestSchema.safeParse({ ...valid, defualtRoute: '/x' });
expect(res.success).toBe(false);
});
it('rejects a verify path that is not a path', () => {
const res = providerFixtureManifestSchema.safeParse({
...valid,
verify: { path: 'v1/models', header: 'x-api-key' },
});
expect(res.success).toBe(false);
});
it('rejects an empty hosts list', () => {
expect(providerFixtureManifestSchema.safeParse({ ...valid, hosts: [] }).success).toBe(false);
});
// The three providers agree on none of this — a manifest that could only
// describe anthropic left the other two's value check unverifiable.
it('accepts a query-param secret instead of a header', () => {
expect(
providerFixtureManifestSchema.safeParse({
...valid,
verify: { path: '/v1beta/models', query: 'key' },
}).success,
).toBe(true);
});
it('accepts a header scheme, for a credential that sends `Bearer <key>`', () => {
expect(
providerFixtureManifestSchema.safeParse({
...valid,
verify: { path: '/models', header: 'Authorization', scheme: 'Bearer' },
}).success,
).toBe(true);
});
it('rejects a verify block carrying neither a header nor a query param', () => {
// The union has no arm without one of them, so this is structural — there is
// no refinement message to assert, only that it cannot parse.
expect(
providerFixtureManifestSchema.safeParse({ ...valid, verify: { path: '/models' } }).success,
).toBe(false);
});
it('rejects a scheme on the query arm, where it could never be honoured', () => {
// `{path, query, scheme}` used to parse and then compare "Bearer <secret>"
// against a query value — a permanent 401 reported as unverifiable.
expect(
providerFixtureManifestSchema.safeParse({
...valid,
verify: { path: '/v1beta/models', query: 'key', scheme: 'Bearer' },
}).success,
).toBe(false);
});
it('rejects a verify block carrying both — the stand-in must know where to look', () => {
const res = providerFixtureManifestSchema.safeParse({
...valid,
verify: { path: '/models', header: 'x-api-key', query: 'key' },
});
expect(res.success).toBe(false);
});
it('accepts a urlField, for a credential whose base URL is not called `url`', () => {
const res = providerFixtureManifestSchema.safeParse({ ...valid, urlField: 'host' });
expect(res.success).toBe(true);
});
});
// The listener n8n's credential test talks to. Getting this wrong silently
// discards the strongest of the three deterministic checks.
describe('the provider stand-in accepts each provider shape', () => {
const SECRET = 'sk-test-0123456789abcdef';
let dir: string;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), 'fixture-verify-'));
await writeFile(
join(dir, 'console.html'),
'<!doctype html><html><body><h1>Keys</h1></body></html>',
);
});
afterAll(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
async function withServer(
verify: Record<string, string>,
run: (server: FixtureServer) => void | Promise<void>,
): Promise<void> {
const server = await startFixtureServer({
fixture: {
id: 'under-test',
dir,
manifest: providerFixtureManifestSchema.parse({
credentialType: 'x',
hosts: ['console.example.com'],
secretPrefix: 'sk-test-',
routes: { '/keys': 'console.html' },
defaultRoute: '/keys',
verify,
}),
},
logger,
secret: SECRET,
});
try {
await run(server);
} finally {
await server.close();
}
}
it('accepts a bare header value, and refuses a truncated one', async () => {
await withServer({ path: '/v1/models', header: 'x-api-key' }, async (server) => {
const url = `${server.verifyBaseUrl}/v1/models`;
expect((await fetch(url, { headers: { 'x-api-key': SECRET } })).status).toBe(200);
expect((await fetch(url, { headers: { 'x-api-key': SECRET.slice(0, 12) } })).status).toBe(
401,
);
expect(server.verifiedOk).toBe(true);
expect(server.verifyAttempts).toBe(2);
});
});
it('accepts a schemed header, so `Bearer <key>` is not read as a wrong key', async () => {
await withServer(
{ path: '/models', header: 'Authorization', scheme: 'Bearer' },
async (server) => {
const url = `${server.verifyBaseUrl}/models`;
expect((await fetch(url, { headers: { Authorization: `Bearer ${SECRET}` } })).status).toBe(
200,
);
// The bare key is the WRONG presentation for this credential type.
expect((await fetch(url, { headers: { Authorization: SECRET } })).status).toBe(401);
},
);
});
it('accepts a query-param secret, which never reaches a header at all', async () => {
await withServer({ path: '/v1beta/models', query: 'key' }, async (server) => {
const base = `${server.verifyBaseUrl}/v1beta/models`;
expect((await fetch(`${base}?key=${SECRET}`)).status).toBe(200);
expect((await fetch(`${base}?key=nope`)).status).toBe(401);
// A query-param provider must not be satisfiable by a header.
expect((await fetch(base, { headers: { 'x-api-key': SECRET } })).status).toBe(401);
});
});
});