mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
335 lines
15 KiB
JavaScript
335 lines
15 KiB
JavaScript
// Workflow HTTP endpoint page object; OSwitch and OCodeBlock suffix their own ids (`-btn`, `-pre`, `-reveal-btn`, `-copy-btn`).
|
|
|
|
const { expect } = require('@playwright/test');
|
|
const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
|
|
const seed = require('./workflowFolderSeed.js');
|
|
|
|
const PANEL_TIMEOUT_MS = 25000;
|
|
const LIST_TIMEOUT_MS = 45000;
|
|
// Rejections are merged into the endpoint row once a minute (rejections.rs MERGE_EVERY).
|
|
const REJECTION_MERGE_TIMEOUT_MS = 150000;
|
|
|
|
const api = () => `${process.env.ZO_BASE_URL}/api`;
|
|
const triggerUrl = (id) => `${api()}/${getOrgIdentifier()}/workflows/${id}/http_trigger`;
|
|
|
|
class WorkflowHttpPage {
|
|
constructor(page) {
|
|
this.page = page;
|
|
this.editorButton = '[data-test="workflow-editor-http-endpoint"]';
|
|
this.editorChip = '[data-test="workflow-editor-http-endpoint-chip"]';
|
|
this.nodeBadge = '[data-test="workflow-trigger-http-badge"]';
|
|
this.panel = '[data-test="workflow-http-panel"]';
|
|
this.panelClose = '[data-test="workflow-http-panel"] [data-test="o-drawer-close-btn"]';
|
|
this.kindNote = '[data-test="workflow-http-kind-note"]';
|
|
this.section = '[data-test="workflow-http-section"]';
|
|
this.toggle = '[data-test="workflow-http-toggle-btn"]';
|
|
this.disableConfirm = '[data-test="workflow-http-disable-confirm"]';
|
|
this.saveAndCreate = '[data-test="workflow-http-save-and-create"]';
|
|
this.draftBanner = '[data-test="workflow-http-draft-banner"]';
|
|
this.reveal = '[data-test="workflow-http-reveal"]';
|
|
this.revealBanner = '[data-test="workflow-http-reveal-banner"]';
|
|
this.revealDone = '[data-test="workflow-http-reveal-done"]';
|
|
this.url = '[data-test="workflow-http-url"]';
|
|
this.tokenPre = '[data-test="workflow-http-token-pre"]';
|
|
this.tokenReveal = '[data-test="workflow-http-token-reveal-btn"]';
|
|
this.tokenCopy = '[data-test="workflow-http-token-copy-btn"]';
|
|
this.sendTest = '[data-test="workflow-http-send-test"]';
|
|
this.sendTestHint = '[data-test="workflow-http-send-test-hint"]';
|
|
this.testResult = '[data-test="workflow-http-test-result"]';
|
|
this.testInput = '[data-test="workflow-http-test-input"]';
|
|
this.runTestInstead = '[data-test="workflow-http-run-test-instead"]';
|
|
this.steady = '[data-test="workflow-http-steady"]';
|
|
this.lastCall = '[data-test="workflow-http-last-call"]';
|
|
this.credentials = '[data-test="workflow-http-credentials"]';
|
|
this.retiringRow = '[data-test="workflow-http-credential-retiring"]';
|
|
this.rotate = '[data-test="workflow-http-rotate"]';
|
|
this.rotatePopover = '[data-test="workflow-http-rotate-popover"]';
|
|
this.rotateOverlap = (s) => `[data-test="workflow-http-rotate-overlap-${s}"]`;
|
|
this.rotateConsequence = '[data-test="workflow-http-rotate-consequence"]';
|
|
this.rotateConfirm = '[data-test="workflow-http-rotate-confirm"]';
|
|
// OTooltip owns its bubble's data-test, so the consumer id never reaches the DOM.
|
|
this.tooltip = '[data-test="o-tooltip-content"]';
|
|
this.credShow = (id) => `[data-test="workflow-http-credential-${id}-show"]`;
|
|
this.credCopy = (id) => `[data-test="workflow-http-credential-${id}-copy"]`;
|
|
this.credValue = (id) => `[data-test="workflow-http-credential-${id}-value"]`;
|
|
this.credRevoke = (id) => `[data-test="workflow-http-credential-${id}-revoke"]`;
|
|
this.credRevokeConfirm = (id) => `[data-test="workflow-http-credential-${id}-revoke-confirm"]`;
|
|
this.revealDenied = '[data-test="workflow-http-credential-reveal-denied"]';
|
|
this.advanced = '[data-test="workflow-http-advanced"]';
|
|
this.runTest = '[data-test="workflow-http-run-test"]';
|
|
this.rejectionsLine = '[data-test="workflow-http-rejections-line"]';
|
|
this.rejectionsShow = '[data-test="workflow-http-rejections-show"]';
|
|
this.rejections = '[data-test="workflow-http-rejections"]';
|
|
this.rejectionRow = (reason) => `[data-test="workflow-http-rejection-${reason}"]`;
|
|
this.testPayloadFrom = '[data-test="workflow-test-payload-from"]';
|
|
this.listHttpTag = '[data-test="workflow-list-http-tag"]';
|
|
this.listMore = (n) => `[data-test="workflow-list-${n}-more-options"]`;
|
|
this.listMenuPanel = (n) => `[data-test="workflow-list-${n}-http-endpoint"]`;
|
|
this.listMenuCopy = (n) => `[data-test="workflow-list-${n}-copy-endpoint"]`;
|
|
this.runsTable = '[data-test="workflow-runs-table"]';
|
|
this.runsHttpFilter = '[data-test="workflow-runs-trigger-filter-http_request"]';
|
|
this.runsSearch = '[data-test="workflow-runs-search"]';
|
|
this.runTestListHint = '[data-test="workflow-http-run-test-list-hint"]';
|
|
}
|
|
|
|
// ---------- API seeding ----------
|
|
|
|
/** A published (or draft) Trigger -> Destination workflow; returns { id, name }. */
|
|
async seedWorkflow({ name, kind = 'alert_fired', draft = false }) {
|
|
const destName = await seed.createPipelineDestination(this.page, `${name}_dest`);
|
|
return await seed.createWorkflow(this.page, { name, destName, draft, triggerKind: kind });
|
|
}
|
|
|
|
async deleteWorkflow(id) {
|
|
await seed.deleteWorkflow(this.page, id).catch(() => {});
|
|
}
|
|
|
|
async deleteDestination(name) {
|
|
await seed.deleteDestination(this.page, `${name}_dest`).catch(() => {});
|
|
}
|
|
|
|
/** Deletes the published workflow or, failing that, the draft saved under `name`. */
|
|
async deleteWorkflowNamed(name) {
|
|
const rows = await seed.listWorkflows(this.page).catch(() => []);
|
|
const row = rows.find((r) => r.name === name);
|
|
if (!row) return;
|
|
const published = await seed.deleteWorkflow(this.page, row.id).catch(() => null);
|
|
if (published?.ok()) return;
|
|
await this.page.request
|
|
.delete(`${api()}/${getOrgIdentifier()}/workflows/${row.id}?draft=true`, {
|
|
headers: getAuthHeaders(),
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
/** Creates an org viewer over REST; returns its email and password. */
|
|
async createViewer(suffix) {
|
|
const email = `wf_auto_viewer_${suffix}@example.com`;
|
|
const password = `Vw#${suffix}Pass2026!`;
|
|
const created = await this.page.request.post(`${api()}/${getOrgIdentifier()}/users`, {
|
|
headers: getAuthHeaders(),
|
|
data: { email, password, role: 'viewer', first_name: 'wf', last_name: 'viewer' },
|
|
});
|
|
expect(created.ok(), await created.text()).toBeTruthy();
|
|
return { email, password };
|
|
}
|
|
|
|
async deleteUser(email) {
|
|
const resp = await this.page.request.delete(
|
|
`${api()}/${getOrgIdentifier()}/users/${encodeURIComponent(email)}`,
|
|
{ headers: getAuthHeaders() }
|
|
);
|
|
expect.soft(resp.ok(), `viewer ${email} not deleted: ${resp.status()}`).toBeTruthy();
|
|
}
|
|
|
|
/** Signs `email` in on a fresh page of `ctx` and returns that page. */
|
|
async loginInContext(ctx, email, password) {
|
|
const vp = await ctx.newPage();
|
|
await vp.goto(`${process.env.ZO_BASE_URL}/web/login?org_identifier=${getOrgIdentifier()}`);
|
|
const internal = vp.locator('[data-test="login-as-internal-user"]');
|
|
if (await internal.isVisible().catch(() => false)) await internal.click();
|
|
await vp.locator('[data-test="login-user-id-field"]').fill(email);
|
|
await vp.locator('[data-test="login-password-field"]').fill(password);
|
|
await vp.locator('[data-test="login-sign-in"]').click();
|
|
await vp.waitForURL((u) => !u.pathname.endsWith('/login'), { timeout: 30000 });
|
|
return vp;
|
|
}
|
|
|
|
/** Turn the endpoint on over REST; the 201 body carries the new token once as `secret`. */
|
|
async enableEndpointApi(id, authMode = 'token') {
|
|
const resp = await this.page.request.put(triggerUrl(id), {
|
|
headers: getAuthHeaders(),
|
|
data: { enabled: true, auth_mode: authMode },
|
|
});
|
|
if (!resp.ok()) throw new Error(`enable endpoint: ${resp.status()} ${(await resp.text()).slice(0, 300)}`);
|
|
return await resp.json();
|
|
}
|
|
|
|
async getEndpointApi(id) {
|
|
const resp = await this.page.request.get(triggerUrl(id), { headers: getAuthHeaders() });
|
|
return { status: resp.status(), body: await resp.json().catch(() => null) };
|
|
}
|
|
|
|
async revealSecretApi(id, credentialId) {
|
|
const resp = await this.page.request.get(`${triggerUrl(id)}/credentials/${credentialId}/secret`, {
|
|
headers: getAuthHeaders(),
|
|
});
|
|
if (!resp.ok()) throw new Error(`reveal secret: ${resp.status()}`);
|
|
return (await resp.json()).secret;
|
|
}
|
|
|
|
/** The active credential's id and value, read through the API the panel uses. */
|
|
async activeSecret(id) {
|
|
const { body } = await this.getEndpointApi(id);
|
|
const active = body.credentials.find((c) => c.state === 'active');
|
|
return { id: active.id, secret: await this.revealSecretApi(id, active.id), url: body.url };
|
|
}
|
|
|
|
/** POST to the public hook URL with plain fetch, so no browser cookie reaches the hook route. */
|
|
async invokeHook(url, { token, body = { service: 'checkout', deploy_id: 'd-4821' }, headers = {}, dryRun = false } = {}) {
|
|
const h = { 'Content-Type': 'application/json', ...headers };
|
|
if (token) h.Authorization = `Bearer ${token}`;
|
|
const target = dryRun ? `${url}${url.includes('?') ? '&' : '?'}dry_run=true` : url;
|
|
const resp = await fetch(target, { method: 'POST', headers: h, body: JSON.stringify(body) });
|
|
const text = await resp.text();
|
|
let json = null;
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch (_e) {
|
|
json = null;
|
|
}
|
|
return { status: resp.status, body: json };
|
|
}
|
|
|
|
/** Wait until the once-a-minute merge has recorded `reason` for this endpoint. */
|
|
async waitForRejection(id, reason) {
|
|
await expect
|
|
.poll(async () => Boolean((await this.getEndpointApi(id)).body?.rejections?.[reason]), {
|
|
timeout: REJECTION_MERGE_TIMEOUT_MS,
|
|
intervals: [5000],
|
|
message: `rejection "${reason}" never merged`,
|
|
})
|
|
.toBe(true);
|
|
}
|
|
|
|
async waitForHttpRun(workflowId, runId) {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const now = Date.now() * 1000;
|
|
const resp = await this.page.request.get(
|
|
`${api()}/${getOrgIdentifier()}/workflows/${workflowId}/history?start_time=${now - 3600e6}&end_time=${now + 60e6}`,
|
|
{ headers: getAuthHeaders() }
|
|
);
|
|
const rows = resp.ok() ? await resp.json() : [];
|
|
return rows.some((r) => r.run_id === runId && r.status !== 'queued' && r.status !== 'running');
|
|
},
|
|
{ timeout: 60000, intervals: [2000], message: `HTTP run ${runId} never finished` }
|
|
)
|
|
.toBe(true);
|
|
}
|
|
|
|
// ---------- panel ----------
|
|
|
|
async grantClipboard() {
|
|
await this.page.context().grantPermissions(['clipboard-read', 'clipboard-write'], {
|
|
origin: new URL(process.env.ZO_BASE_URL).origin,
|
|
});
|
|
}
|
|
|
|
async clipboardText() {
|
|
return await this.page.evaluate(() => navigator.clipboard.readText());
|
|
}
|
|
|
|
async openFromEditor() {
|
|
await this.page.locator(this.editorButton).click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.panel).waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
async openFromList(name) {
|
|
await this.page.locator(this.listMore(name)).first().click({ timeout: LIST_TIMEOUT_MS });
|
|
await this.page.locator(this.listMenuPanel(name)).first().click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.panel).waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
async copyUrlFromList(name) {
|
|
await this.page.locator(this.listMore(name)).first().click({ timeout: LIST_TIMEOUT_MS });
|
|
await this.page.locator(this.listMenuCopy(name)).first().click({ timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
async closePanel() {
|
|
await this.page.locator(this.panelClose).click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.panel).waitFor({ state: 'hidden', timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
async panelText() {
|
|
return (await this.page.locator(this.panel).innerText()).replace(/\s+/g, ' ');
|
|
}
|
|
|
|
async turnOn() {
|
|
await this.page.locator(this.toggle).click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.reveal).waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
async tokenOnScreen() {
|
|
return ((await this.page.locator(this.tokenPre).innerText()) || '').trim();
|
|
}
|
|
|
|
async revealToken() {
|
|
await this.page.locator(this.tokenReveal).click({ timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
async copyToken() {
|
|
await this.page.locator(this.tokenCopy).click({ timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
async urlOnScreen() {
|
|
return ((await this.page.locator(this.url).innerText()) || '').trim();
|
|
}
|
|
|
|
/** Send test request (dry run) and return the result box text. */
|
|
async sendDryRun() {
|
|
await this.page.locator(this.sendTest).click({ timeout: PANEL_TIMEOUT_MS });
|
|
const box = this.page.locator(this.testResult);
|
|
await box.waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
return (await box.innerText()).replace(/\s+/g, ' ');
|
|
}
|
|
|
|
async finishReveal() {
|
|
await this.page.locator(this.revealDone).click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.steady).waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
async rotateWithOverlap(seconds) {
|
|
await this.page.locator(this.rotate).click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.rotatePopover).waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.rotateOverlap(seconds)).click({ timeout: PANEL_TIMEOUT_MS });
|
|
const consequence = (await this.page.locator(this.rotateConsequence).innerText()).trim();
|
|
await this.page.locator(this.rotateConfirm).click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.retiringRow).waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
return consequence;
|
|
}
|
|
|
|
/** Rotate with no live credential: the popover offers no overlap, confirm mints a new one. */
|
|
async rotateWithoutCredential() {
|
|
await this.page.locator(this.rotate).click({ timeout: PANEL_TIMEOUT_MS });
|
|
const popover = this.page.locator(this.rotatePopover);
|
|
await popover.waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
const lead = (await popover.innerText()).replace(/\s+/g, ' ');
|
|
const overlapOptions = await this.page.locator(this.rotateOverlap(3600)).count();
|
|
await this.page.locator(this.rotateConfirm).click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.revealBanner).waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
return { lead, overlapOptions };
|
|
}
|
|
|
|
async revokeCredential(credentialId) {
|
|
await this.page.locator(this.credRevoke(credentialId)).click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.credRevokeConfirm(credentialId)).click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.credRevoke(credentialId)).waitFor({ state: 'detached', timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
async showRejections() {
|
|
await this.page.locator(this.rejectionsLine).waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.rejectionsShow).click({ timeout: PANEL_TIMEOUT_MS });
|
|
await this.page.locator(this.rejections).waitFor({ state: 'visible', timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
// ---------- runs ----------
|
|
|
|
async goToRuns(workflowId) {
|
|
const url = `${process.env.ZO_BASE_URL}/web/workflows/runs?id=${workflowId}&org_identifier=${getOrgIdentifier()}`;
|
|
await this.page.goto(url, { timeout: 60000 });
|
|
await this.page.locator(this.runsTable).first().waitFor({ state: 'visible', timeout: LIST_TIMEOUT_MS });
|
|
}
|
|
|
|
async filterRunsToHttp() {
|
|
await this.page.locator(this.runsHttpFilter).first().click({ timeout: PANEL_TIMEOUT_MS });
|
|
}
|
|
|
|
async runsTableText() {
|
|
return (await this.page.locator(this.runsTable).first().innerText()).replace(/\s+/g, ' ');
|
|
}
|
|
}
|
|
|
|
module.exports = WorkflowHttpPage;
|