mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
788 lines
33 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|