// 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;