const http = require('http'); const https = require('https'); const fetch = require('node-fetch'); const testLogger = require('../playwright-tests/utils/test-logger.js'); const { getAuthHeaders, getOrgIdentifier, isCloudEnvironment } = require('../playwright-tests/utils/cloud-auth.js'); const { ingestBase } = require('../playwright-tests/utils/ingest-url.js'); // node-fetch v2 keep-alive pooling + gzip decompression is the root cause of // "Premature close" / ECONNRESET flakiness in CI. // Pick the agent by protocol so both local (http://localhost) and cloud/alpha // (https://) URLs work — an http.Agent rejects https:// URLs. const noKeepAliveHttpAgent = new http.Agent({ keepAlive: false }); const noKeepAliveHttpsAgent = new https.Agent({ keepAlive: false }); const selectAgent = (parsedURL) => parsedURL.protocol === 'https:' ? noKeepAliveHttpsAgent : noKeepAliveHttpAgent; class APICleanup { constructor(page = null) { this._page = page; this.baseUrl = process.env.ZO_BASE_URL; this.org = getOrgIdentifier(); this.email = process.env.ZO_ROOT_USER_EMAIL || process.env.ALPHA1_USER_EMAIL; this.password = process.env.ZO_ROOT_USER_PASSWORD || process.env.ALPHA1_USER_PASSWORD; // Use centralized auth headers (cloud: email:passcode, self-hosted: email:password) const headers = getAuthHeaders(); this.authHeader = headers['Authorization'] || ''; } /** * Fetch wrapper: on cloud uses page.evaluate (browser cookies for OIDC auth), * on self-hosted uses node-fetch with Basic Auth. * Falls back to node-fetch if page is unavailable. */ async _fetch(url, options = {}) { if (this._page && isCloudEnvironment()) { // Ensure page is on the same origin so session cookies are sent with fetch // Re-navigating an already-open app page would reset the test's UI state and org context. const onAppOrigin = (() => { try { return new URL(this._page.url()).origin === new URL(this.baseUrl).origin; } catch { return false; } })(); if (!this._pageNavigated && !onAppOrigin) { try { await this._page.goto(`${this.baseUrl}/web/?org_identifier=${this.org}`, { waitUntil: 'domcontentloaded' }); this._pageNavigated = true; } catch (e) { testLogger.warn('Failed to navigate page to baseUrl for cookie auth', { error: e.message }); } } try { // Strip empty Authorization header so browser relies on session cookies (OIDC cloud auth) const browserOpts = { ...options, headers: Object.fromEntries(Object.entries(options.headers || {}).filter(([k, v]) => !(k.toLowerCase() === 'authorization' && !v))) }; const result = await this._page.evaluate(async ({ url, options }) => { const r = await fetch(url, options); const text = await r.text(); const contentType = r.headers.get('content-type') || ''; return { ok: r.ok, status: r.status, text, contentType }; }, { url, options: browserOpts }); return { ok: result.ok, status: result.status, headers: { get: (name) => name.toLowerCase() === 'content-type' ? result.contentType : null }, json: async () => JSON.parse(result.text), text: async () => result.text, }; } catch (e) { testLogger.warn('page.evaluate fetch failed, falling back to node-fetch', { url: url.substring(0, 80), error: e.message }); } } return fetch(url, { ...options, compress: false, agent: selectAgent }); } /** * Fetch all destinations and build template-destination mapping * @returns {Promise} Object with destinations array and templateToDestinations map */ async fetchDestinationsWithTemplateMapping() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/alerts/destinations?page_num=1&page_size=100000&sort_by=name&desc=false&module=alert`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch destinations', { status: response.status }); return { destinations: [], templateToDestinations: {} }; } const destinations = await response.json(); // Build map of template name -> destination names const templateToDestinations = {}; for (const dest of destinations) { if (dest.template) { if (!templateToDestinations[dest.template]) { templateToDestinations[dest.template] = []; } templateToDestinations[dest.template].push(dest.name); } } return { destinations, templateToDestinations }; } catch (error) { testLogger.error('Failed to fetch destinations', { error: error.message }); return { destinations: [], templateToDestinations: {} }; } } /** * Fetch all alert folders * @returns {Promise} Array of folder objects with folderId and name */ async fetchAlertFolders() { try { const response = await this._fetch(`${this.baseUrl}/api/v2/${this.org}/folders/alerts`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch alert folders', { status: response.status }); return []; } const data = await response.json(); return data.list || []; } catch (error) { testLogger.error('Failed to fetch alert folders', { error: error.message }); return []; } } /** * Fetch alerts in a specific folder * @param {string} folderId - The folder ID * @returns {Promise} Array of alert objects */ async fetchAlertsInFolder(folderId) { try { const response = await this._fetch(`${this.baseUrl}/api/v2/${this.org}/alerts?sort_by=name&desc=false&name=&folder=${folderId}`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch alerts in folder', { folderId, status: response.status }); return []; } const data = await response.json(); return data.list || []; } catch (error) { testLogger.error('Failed to fetch alerts in folder', { folderId, error: error.message }); return []; } } /** * Delete a single alert * @param {string} alertId - The alert ID * @param {string} folderId - The folder ID * @returns {Promise} Deletion result */ async deleteAlert(alertId, folderId) { try { const response = await this._fetch(`${this.baseUrl}/api/v2/${this.org}/alerts/${alertId}?folder=${folderId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); const result = await response.json(); return result; } catch (error) { testLogger.error('Failed to delete alert', { alertId, folderId, error: error.message }); return { code: 500, error: error.message }; } } /** * Delete a folder * @param {string} folderId - The folder ID * @returns {Promise} Deletion result */ async deleteFolder(folderId) { try { const response = await this._fetch(`${this.baseUrl}/api/v2/${this.org}/folders/alerts/${folderId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); // API returns plain text "Folder deleted" instead of JSON const text = await response.text(); // If successful, return standardized response if (response.ok) { return { code: 200, message: text }; } // Try to parse as JSON for error responses try { const jsonResult = JSON.parse(text); return jsonResult; } catch { return { code: response.status, message: text }; } } catch (error) { testLogger.error('Failed to delete folder', { folderId, error: error.message }); return { code: 500, error: error.message }; } } /** * Create an alerts folder via the v2 folders API. * @param {string} name - Unique folder name * @returns {Promise<{folderId: string, name: string}>} */ async createAlertFolder(name) { const response = await this._fetch( `${this.baseUrl}/api/v2/${this.org}/folders/alerts`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description: '' }) } ); if (!response.ok) { const body = await response.text(); throw new Error(`createAlertFolder: HTTP ${response.status} — ${body}`); } const folder = await response.json(); testLogger.info('Created alert folder', { name, folderId: folder.folderId }); return { folderId: folder.folderId, name: folder.name }; } /** * Seed `count` minimal scheduled alerts into a folder. * Alert creation validates the source stream exists, so a single row is ingested * into a dedicated stream first. * @param {string} folderId * @param {number} count * @param {string} prefix - Alert name prefix (deterministic ordering by name); also used * to derive the source stream name * @returns {Promise>} */ async seedAlertsInFolder(folderId, count, prefix) { const streamName = `${prefix}_stream`; await this._fetch(`${ingestBase(this.baseUrl)}/api/${this.org}/${streamName}/_json`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify([{ message: 'e2e pagination seed row' }]) }); await this.waitForStreamSchema(streamName); // Seed alerts deliver somewhere, so they get a destination. const templateName = `${prefix}_tmpl`; const destinationName = `${prefix}_dest`; await this.createAlertTemplate(templateName); await this.createAlertDestination(destinationName, templateName); const created = []; for (let i = 0; i < count; i++) { const name = `${prefix}_${i}`; const payload = { name, stream_type: 'logs', stream_name: streamName, is_real_time: false, query_condition: { type: 'custom', conditions: { version: 2, conditions: { filterType: 'group', logicalOperator: 'AND', conditions: [] } }, sql: null, promql: null, promql_condition: null, aggregation: null, vrl_function: null, search_event_type: null, multi_time_range: [], }, trigger_condition: { period: 10, operator: '>=', threshold: 3, frequency: 10, cron: '', frequency_type: 'minutes', silence: 10, timezone: 'UTC', align_time: true, }, destinations: [destinationName], context_attributes: {}, row_template: '', enabled: false, }; const response = await this._fetch( `${this.baseUrl}/api/v2/${this.org}/alerts?folder=${folderId}`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } ); if (!response.ok) { const body = await response.text(); throw new Error(`seedAlertsInFolder: HTTP ${response.status} — ${body}`); } created.push({ name }); } return { alerts: created, templateName, destinationName, streamName }; } /** * Poll a freshly-ingested stream's settings endpoint until its schema is * registered. Alert creation validates the source stream exists, and schema * registration can lag a moment behind the ingest write on a busy shared env. * @param {string} streamName * @param {number} maxWaitMs */ async waitForStreamSchema(streamName, maxWaitMs = 15000) { const deadline = Date.now() + maxWaitMs; while (Date.now() < deadline) { const response = await this._fetch( `${this.baseUrl}/api/${this.org}/streams/${streamName}/settings?type=logs`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } } ); if (response.ok) return; await new Promise((resolve) => setTimeout(resolve, 1000)); } testLogger.warn('Stream schema did not register in time', { streamName, maxWaitMs }); } /** * Fetch all alert templates (prebuilt + custom). * @returns {Promise} Array of template objects */ async fetchAlertTemplates() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/alerts/templates`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch alert templates', { status: response.status }); return []; } return await response.json(); } catch (error) { testLogger.error('Failed to fetch alert templates', { error: error.message }); return []; } } /** * Create an alert notification template via API. * @param {string} name * @param {string} [body] - Defaults to a minimal valid template body */ async createAlertTemplate(name, body = null) { const templateBody = body || '{"text": "{alert_name} is active"}'; const response = await this._fetch( `${this.baseUrl}/api/${this.org}/alerts/templates`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, body: templateBody }) } ); if (!response.ok) { const errBody = await response.text(); throw new Error(`createAlertTemplate: HTTP ${response.status} — ${errBody}`); } testLogger.info('Created alert template via API', { name }); } /** * Seed `count` minimal alert templates. * @param {number} count * @param {string} prefix * @returns {Promise>} */ async seedAlertTemplates(count, prefix) { const created = []; for (let i = 0; i < count; i++) { const name = `${prefix}_${i}`; await this.createAlertTemplate(name); created.push({ name }); } return created; } /** * Delete a single alert template by name. * @param {string} name * @returns {Promise} { code, message } */ async deleteAlertTemplate(name) { try { const response = await this._fetch( `${this.baseUrl}/api/${this.org}/alerts/templates/${encodeURIComponent(name)}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } } ); const text = await response.text(); return { code: response.ok ? 200 : response.status, message: text }; } catch (error) { testLogger.error('Failed to delete alert template', { name, error: error.message }); return { code: 500, error: error.message }; } } /** * Create an HTTP alert destination via API. example.com is IANA-reserved and never * contacted — no spec fires these alerts, so the destination just needs a URL the * server's SSRF guard accepts (it blocks localhost/private targets). * @param {string} name * @param {string} templateName - Must reference an existing template */ async createAlertDestination(name, templateName) { const payload = { name, url: 'http://example.com/e2e_pag_sink', method: 'post', template: templateName, type: 'http', headers: {} }; const response = await this._fetch( `${this.baseUrl}/api/${this.org}/alerts/destinations`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } ); if (!response.ok) { const errBody = await response.text(); throw new Error(`createAlertDestination: HTTP ${response.status} — ${errBody}`); } testLogger.info('Created alert destination via API', { name }); } /** * Seed `count` minimal HTTP alert destinations, all sharing one template. * @param {number} count * @param {string} prefix * @param {string} templateName * @returns {Promise>} */ async seedAlertDestinations(count, prefix, templateName) { const created = []; for (let i = 0; i < count; i++) { const name = `${prefix}_${i}`; await this.createAlertDestination(name, templateName); created.push({ name }); } return created; } /** * Delete a single alert destination by name. * @param {string} name * @returns {Promise} { code, message } */ async deleteAlertDestination(name) { try { const response = await this._fetch( `${this.baseUrl}/api/${this.org}/alerts/destinations/${encodeURIComponent(name)}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } } ); const text = await response.text(); return { code: response.ok ? 200 : response.status, message: text }; } catch (error) { testLogger.error('Failed to delete alert destination', { name, error: error.message }); return { code: 500, error: error.message }; } } /** * Fetch all dashboard folders * @returns {Promise} Array of folder objects */ async fetchDashboardFolders() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/folders`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch dashboard folders', { status: response.status }); return []; } const folders = await response.json(); return folders || []; } catch (error) { testLogger.error('Failed to fetch dashboard folders', { error: error.message }); return []; } } /** * Create a dashboards folder via the v2 folders API. * @param {string} name - Unique folder name * @returns {Promise<{folderId: string, name: string}>} */ async createDashboardFolder(name) { const response = await this._fetch( `${this.baseUrl}/api/v2/${this.org}/folders/dashboards`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description: '', icon: null }) } ); if (!response.ok) { const body = await response.text(); throw new Error(`createDashboardFolder: HTTP ${response.status} — ${body}`); } const folder = await response.json(); testLogger.info('Created dashboard folder', { name, folderId: folder.folderId }); return { folderId: folder.folderId, name: folder.name }; } /** * Seed `count` minimal dashboards into a folder via createMinimalDashboard. * @param {string} folderId * @param {number} count * @param {string} prefix - Dashboard title prefix (deterministic ordering by name) * @returns {Promise>} */ async seedDashboardsInFolder(folderId, count, prefix) { const created = []; for (let i = 0; i < count; i++) { const title = `${prefix}_${i}`; const { dashboardId } = await this.createMinimalDashboard(title, folderId); created.push({ dashboardId, title }); } return created; } /** * Delete a dashboards folder by id. Dashboards inside must be removed first, * otherwise the backend returns 400 ("Folder contains dashboards"). * @param {string} folderId * @returns {Promise} { code, message } */ async deleteDashboardFolder(folderId) { try { const response = await this._fetch( `${this.baseUrl}/api/v2/${this.org}/folders/dashboards/${folderId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } } ); const text = await response.text(); if (response.ok) { return { code: 200, message: text }; } testLogger.warn('Failed to delete dashboard folder', { folderId, status: response.status, body: text }); return { code: response.status, message: text }; } catch (error) { testLogger.error('Failed to delete dashboard folder', { folderId, error: error.message }); return { code: 500, error: error.message }; } } /** * Fetch all dashboards in a specific folder * @param {string} folderName - The folder name (e.g., 'default') * @returns {Promise} Array of dashboard objects */ async fetchDashboardsInFolder(folderName = 'default') { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/dashboards?page_num=0&page_size=1000&sort_by=name&desc=false&name=&folder=${folderName}`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch dashboards', { folder: folderName, status: response.status }); return []; } const data = await response.json(); return data.dashboards || []; } catch (error) { testLogger.error('Failed to fetch dashboards', { folder: folderName, error: error.message }); return []; } } /** * Create a minimal dashboard for use as a report dependency. * Returns { dashboardId, folderId } or throws on failure. */ async createMinimalDashboard(title = 'E2E Setup Dashboard', folderId = 'default') { const payload = { title, description: '', role: '', owner: this.email, tabs: [{ tabId: 'default', name: 'Default', panels: [] }], variables: {} }; const response = await this._fetch( `${this.baseUrl}/api/${this.org}/dashboards?folder=${encodeURIComponent(folderId)}`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } ); if (!response.ok) { const body = await response.text(); throw new Error(`createMinimalDashboard: HTTP ${response.status} — ${body}`); } const result = await response.json(); // MetaDashboard nests the actual data under the version key (e.g. "v8"). const inner = result[`v${result.version}`] || result; const dashboardId = inner.dashboardId || inner.dashboard_id || result.dashboard_id || result.id; testLogger.info('Created minimal dashboard', { dashboardId, folderId }); return { dashboardId, folderId }; } /** * Create a dashboard carrying one single-query SQL panel. * @param {string} title - Dashboard title * @param {string} panelTitle - Panel title, used verbatim in the generated alert name * @param {string} streamName - Logs stream the panel queries * @param {string} [folderId] - Folder to create in * @returns {Promise<{dashboardId: string, folderId: string}>} */ async createDashboardWithPanel(title, panelTitle, streamName, folderId = 'default') { const payload = { version: 5, title, description: '', role: '', owner: this.email, tabs: [ { tabId: 'default', name: 'Default', panels: [ { id: `Panel_ID${Date.now()}`, type: 'bar', title: panelTitle, description: '', config: { show_legends: false, decimals: 2, drilldown: [] }, queryType: 'sql', queries: [ { query: `SELECT histogram(_timestamp) as "x_axis_1", count(_timestamp) as "y_axis_1" FROM "${streamName}" GROUP BY x_axis_1`, vrlFunctionQuery: '', customQuery: false, fields: { stream: streamName, stream_type: 'logs', x: [{ label: 'Timestamp', alias: 'x_axis_1', column: '_timestamp', color: null, aggregationFunction: 'histogram' }], y: [{ label: 'Count', alias: 'y_axis_1', column: '_timestamp', color: '#5960b2', aggregationFunction: 'count' }], z: [], breakdown: [], filter: { filterType: 'group', logicalOperator: 'AND', conditions: [] }, }, config: { promql_legend: '', layer_type: 'scatter', weight_fixed: 1, limit: 0, min: 0, max: 100 }, }, ], layout: { x: 0, y: 0, w: 24, h: 9, i: 1 }, }, ], }, ], variables: {}, }; const response = await this._fetch( `${this.baseUrl}/api/${this.org}/dashboards?folder=${encodeURIComponent(folderId)}`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } ); if (!response.ok) { const body = await response.text(); throw new Error(`createDashboardWithPanel: HTTP ${response.status} — ${body}`); } const result = await response.json(); const inner = result[`v${result.version}`] || result; const dashboardId = inner.dashboardId || inner.dashboard_id || result.dashboard_id || result.id; testLogger.info('Created dashboard with panel', { dashboardId, panelTitle, folderId }); return { dashboardId, folderId }; } /** * Create a dashboard with `panelCount` stacked bar panels, each laid out at * full width (w:24, h:8) and offset vertically (y: 0, 9, 18, ...) so the grid * is tall enough to exceed one print page. Used by the dashboard print-layout * specs, which need a deterministic multi-panel grid geometry. * @param {string} title - Dashboard title * @param {number} [panelCount=4] - Number of stacked panels * @param {string} [streamName='e2e_automate'] - Logs stream each panel queries * @returns {Promise<{dashboardId: string, folderId: string}>} */ async createDashboardWithStackedPanels(title, panelCount = 4, streamName = 'e2e_automate') { const panels = []; for (let i = 0; i < panelCount; i++) { panels.push({ id: `Panel_ID${Date.now()}_${i}`, type: 'bar', title: `${title} Panel ${i + 1}`, description: '', config: { show_legends: false, decimals: 2, drilldown: [] }, queryType: 'sql', queries: [ { query: `SELECT histogram(_timestamp) as "x_axis_1", count(_timestamp) as "y_axis_1" FROM "${streamName}" GROUP BY x_axis_1`, vrlFunctionQuery: '', customQuery: false, fields: { stream: streamName, stream_type: 'logs', x: [{ label: 'Timestamp', alias: 'x_axis_1', column: '_timestamp', color: null, aggregationFunction: 'histogram' }], y: [{ label: 'Count', alias: 'y_axis_1', column: '_timestamp', color: '#5960b2', aggregationFunction: 'count' }], z: [], breakdown: [], filter: { filterType: 'group', logicalOperator: 'AND', conditions: [] }, }, config: { promql_legend: '', layer_type: 'scatter', weight_fixed: 1, limit: 0, min: 0, max: 100 }, }, ], layout: { x: 0, y: i * 9, w: 24, h: 8, i: i + 1 }, }); } const payload = { version: 5, title, description: '', role: '', owner: this.email, tabs: [{ tabId: 'default', name: 'Default', panels }], variables: {}, }; const response = await this._fetch( `${this.baseUrl}/api/${this.org}/dashboards?folder=${encodeURIComponent('default')}`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } ); if (!response.ok) { const body = await response.text(); throw new Error(`createDashboardWithStackedPanels: HTTP ${response.status} — ${body}`); } const result = await response.json(); const inner = result[`v${result.version}`] || result; const dashboardId = inner.dashboardId || inner.dashboard_id || result.dashboard_id || result.id; testLogger.info('Created dashboard with stacked panels', { dashboardId, panelCount, folderId: 'default' }); return { dashboardId, folderId: 'default' }; } /** * Delete a single dashboard * @param {string} dashboardId - The dashboard ID * @param {string} folderId - The folder ID * @returns {Promise} Deletion result */ async deleteDashboard(dashboardId, folderId) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/dashboards/${dashboardId}?folder=${folderId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete dashboard', { dashboardId, folderId, status: response.status }); return { code: response.status, message: 'Failed to delete dashboard' }; } const result = await response.json(); return result; } catch (error) { testLogger.error('Failed to delete dashboard', { dashboardId, folderId, error: error.message }); return { code: 500, error: error.message }; } } /** * Fetch all reports * @returns {Promise} Array of report objects */ async fetchReports() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/reports`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch reports', { status: response.status }); return []; } const reports = await response.json(); return reports || []; } catch (error) { testLogger.error('Failed to fetch reports', { error: error.message }); return []; } } /** * Delete a single report * @param {string} reportName - The report name * @returns {Promise} Deletion result */ async deleteReport(reportName) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/reports/${reportName}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete report', { reportName, status: response.status }); return { code: response.status, message: 'Failed to delete report' }; } const result = await response.json(); return result; } catch (error) { testLogger.error('Failed to delete report', { reportName, error: error.message }); return { code: 500, error: error.message }; } } /** * Delete a single report by its id (v2, folder-aware). * * Prefer this over deleteReport() whenever a report may live outside the * default folder: the v1 `DELETE /api/{org}/reports/{name}` route resolves * names within the default folder only and returns 404 for a report saved * in a custom report folder, which then blocks deleting that folder. * * @param {string} reportId - The report id (`report_id` from fetchReports) * @param {string} reportName - Optional, for logging only * @returns {Promise} { code, message } */ async deleteReportById(reportId, reportName = '') { try { const response = await this._fetch(`${this.baseUrl}/api/v2/${this.org}/reports/${reportId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); const text = await response.text(); if (!response.ok) { testLogger.error('Failed to delete report by id', { reportId, reportName, status: response.status, body: text }); return { code: response.status, message: text }; } return { code: 200, message: text }; } catch (error) { testLogger.error('Failed to delete report by id', { reportId, reportName, error: error.message }); return { code: 500, message: error.message, error: error.message }; } } /** * Fetch all pipelines * @returns {Promise} Array of pipeline objects */ async fetchPipelines() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/pipelines`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch pipelines', { status: response.status }); return []; } const data = await response.json(); return data.list || []; } catch (error) { testLogger.error('Failed to fetch pipelines', { error: error.message }); return []; } } /** * Delete a single pipeline * @param {string} pipelineId - The pipeline ID * @returns {Promise} Deletion result */ async deletePipeline(pipelineId) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/pipelines/${pipelineId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete pipeline', { pipelineId, status: response.status }); return { code: response.status, message: 'Failed to delete pipeline' }; } const result = await response.json(); return result; } catch (error) { testLogger.error('Failed to delete pipeline', { pipelineId, error: error.message }); return { code: 500, error: error.message }; } } /** * Fetch all functions in a specific organization * @param {string} org - The organization identifier (defaults to 'default') * @returns {Promise} Array of function objects */ async fetchFunctionsInOrg(org = 'default') { try { const response = await this._fetch(`${this.baseUrl}/api/${org}/functions?page_num=1&page_size=100000&sort_by=name&desc=false&name=`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch functions', { org, status: response.status }); return []; } const data = await response.json(); return data.list || []; } catch (error) { testLogger.error('Failed to fetch functions', { org, error: error.message }); return []; } } /** * Create a VRL function via API. * @param {string} functionName * @param {string} vrlCode - Function body; must end with a trailing `.` * @param {string} [org] - Organization identifier */ async createFunction(functionName, vrlCode, org = null) { const targetOrg = org || this.org; const response = await this._fetch(`${this.baseUrl}/api/${targetOrg}/functions`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: functionName, function: vrlCode, params: 'row', transType: 0 }) }); if (!response.ok) { const body = await response.text(); throw new Error(`createFunction: HTTP ${response.status} — ${body}`); } testLogger.info('Created function via API', { functionName, org: targetOrg }); return await response.json().catch(() => ({})); } /** * Create a realtime pipeline whose middle node calls an existing function, * so the function has a dependent the UI can warn about. * @param {string} pipelineName * @param {string} functionName - must already exist * @param {string} [sourceStream] * @param {string} [org] * @returns {Promise} the new pipeline's id */ async createPipelineUsingFunction(pipelineName, functionName, sourceStream = 'e2e_automate', org = null) { const targetOrg = org || this.org; const stamp = Date.now(); const inputId = `in-${stamp}`; const fnId = `fn-${stamp}`; const outputId = `out-${stamp}`; const edge = (id, source, target) => ({ id, source, target, type: 'custom', animated: true, updatable: true, markerEnd: { type: 'arrowclosed', width: 20, height: 20 }, style: { strokeWidth: 2 }, }); const payload = { pipeline_id: '', version: 0, enabled: true, org: targetOrg, name: pipelineName, description: `E2E pipeline using ${functionName}`, source: { source_type: 'realtime' }, paused_at: null, nodes: [ { id: inputId, position: { x: 100, y: 100 }, io_type: 'input', data: { node_type: 'stream', stream_type: 'logs', stream_name: sourceStream, org_id: targetOrg } }, { id: fnId, position: { x: 300, y: 200 }, io_type: 'default', data: { node_type: 'function', name: functionName, after_flatten: true } }, { id: outputId, position: { x: 500, y: 300 }, io_type: 'output', data: { node_type: 'stream', stream_type: 'logs', stream_name: `${pipelineName}_dest`, org_id: targetOrg } }, ], edges: [edge(`e1-${stamp}`, inputId, fnId), edge(`e2-${stamp}`, fnId, outputId)], }; const response = await this._fetch(`${this.baseUrl}/api/${targetOrg}/pipelines`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const body = await response.text(); throw new Error(`createPipelineUsingFunction: HTTP ${response.status} — ${body}`); } const result = await response.json().catch(() => ({})); testLogger.info('Created pipeline using function', { pipelineName, functionName, org: targetOrg }); return result.id; } /** * Create a realtime pipeline with no function node — stream straight to * stream. The counterpart to createPipelineUsingFunction, for showing that a * pipeline calling nothing is exported without a `functions` key. * @param {string} pipelineName * @param {string} [sourceStream] * @param {string} [org] * @returns {Promise} the new pipeline's id */ async createPlainPipeline(pipelineName, sourceStream = 'e2e_automate', org = null) { const targetOrg = org || this.org; const stamp = Date.now(); const inputId = `in-${stamp}`; const outputId = `out-${stamp}`; const payload = { pipeline_id: '', version: 0, enabled: true, org: targetOrg, name: pipelineName, description: 'E2E pipeline with no function node', source: { source_type: 'realtime' }, paused_at: null, nodes: [ { id: inputId, position: { x: 100, y: 100 }, io_type: 'input', data: { node_type: 'stream', stream_type: 'logs', stream_name: sourceStream, org_id: targetOrg } }, { id: outputId, position: { x: 500, y: 300 }, io_type: 'output', data: { node_type: 'stream', stream_type: 'logs', stream_name: `${pipelineName}_dest`, org_id: targetOrg } }, ], edges: [{ id: `e1-${stamp}`, source: inputId, target: outputId, type: 'custom', animated: true, updatable: true, markerEnd: { type: 'arrowclosed', width: 20, height: 20 }, style: { strokeWidth: 2 }, }], }; const response = await this._fetch(`${this.baseUrl}/api/${targetOrg}/pipelines`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const body = await response.text(); throw new Error(`createPlainPipeline: HTTP ${response.status} — ${body}`); } const result = await response.json().catch(() => ({})); testLogger.info('Created pipeline with no function node', { pipelineName, org: targetOrg }); return result.id; } /** * Create a JavaScript function (transType 1) via API. * @param {string} functionName * @param {string} jsCode - Function body; JS is not VRL, so it is stored verbatim * @param {string} [org] - Organization identifier */ async createJsFunction(functionName, jsCode, org = null) { const targetOrg = org || this.org; const response = await this._fetch(`${this.baseUrl}/api/${targetOrg}/functions`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: functionName, function: jsCode, params: 'row', transType: 1 }) }); if (!response.ok) { const body = await response.text(); throw new Error(`createJsFunction: HTTP ${response.status} — ${body}`); } testLogger.info('Created JS function via API', { functionName, org: targetOrg }); return await response.json().catch(() => ({})); } /** * Replace a function's body in place (PUT), leaving every pipeline that * calls it pointing at it. * * The alternative — delete and recreate — is refused with a 409 while any * pipeline depends on the function, so this is the only way to change the * logic behind a live dependent. * @param {string} functionName - must already exist * @param {string} vrlCode * @param {string} [org] */ async updateFunction(functionName, vrlCode, org = null) { const targetOrg = org || this.org; const response = await this._fetch(`${this.baseUrl}/api/${targetOrg}/functions/${functionName}`, { method: 'PUT', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: functionName, function: vrlCode, params: 'row', transType: 0 }) }); if (!response.ok) { const body = await response.text(); throw new Error(`updateFunction: HTTP ${response.status} — ${body}`); } testLogger.info('Updated function via API', { functionName, org: targetOrg }); return await response.json().catch(() => ({})); } /** * Delete a single function in a specific organization * @param {string} org - The organization identifier * @param {string} functionName - The function name * @returns {Promise} Deletion result */ async deleteFunctionInOrg(org, functionName) { try { const response = await this._fetch(`${this.baseUrl}/api/${org}/functions/${functionName}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete function', { org, functionName, status: response.status }); return { code: response.status, message: 'Failed to delete function' }; } const result = await response.json(); return result; } catch (error) { testLogger.error('Failed to delete function', { org, functionName, error: error.message }); return { code: 500, error: error.message }; } } /** * Clean up functions matching patterns in a specific organization * @param {string} org - The organization identifier * @param {Array} patterns - Array of regex patterns to match function names */ async cleanupFunctionsInOrg(org, patterns = []) { testLogger.info(`Starting functions cleanup in ${org} org`, { patterns: patterns.map(p => p.source) }); try { // Fetch all functions in the specified org const functions = await this.fetchFunctionsInOrg(org); testLogger.info(`Fetched functions from ${org}`, { total: functions.length }); // Filter functions matching patterns const matchingFunctions = functions.filter(f => patterns.some(pattern => pattern.test(f.name)) ); testLogger.info(`Found functions matching cleanup patterns in ${org}`, { count: matchingFunctions.length }); if (matchingFunctions.length === 0) { testLogger.info(`No functions to clean up in ${org}`); return; } // Delete each function let deletedCount = 0; let failedCount = 0; for (const func of matchingFunctions) { const result = await this.deleteFunctionInOrg(org, func.name); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted function', { org, name: func.name }); } else { failedCount++; testLogger.warn('Failed to delete function', { org, name: func.name, result }); } } testLogger.info(`Functions cleanup completed in ${org}`, { deletedCount, failedCount }); } catch (error) { testLogger.error(`Failed to cleanup functions in ${org}`, { error: error.message }); } } /** * Fetch all enrichment tables * @returns {Promise} Array of enrichment table objects */ async fetchEnrichmentTables() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/streams?type=enrichment_tables`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch enrichment tables', { status: response.status }); return []; } const data = await response.json(); return data.list || []; } catch (error) { testLogger.error('Failed to fetch enrichment tables', { error: error.message }); return []; } } /** * Delete a single enrichment table * @param {string} tableName - The enrichment table name * @returns {Promise} Deletion result */ async deleteEnrichmentTable(tableName) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/streams/${tableName}?type=enrichment_tables&delete_all=true`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete enrichment table', { tableName, status: response.status }); return { code: response.status, message: 'Failed to delete enrichment table' }; } const result = await response.json(); return result; } catch (error) { testLogger.error('Failed to delete enrichment table', { tableName, error: error.message }); return { code: 500, error: error.message }; } } /** * Clean up file-based enrichment tables matching specified patterns * These are tables uploaded via file and tracked in /api/{org}/streams?type=enrichment_tables * @param {Array} patterns - Array of regex patterns to match table names */ async cleanupFileEnrichmentTables(patterns = []) { testLogger.info('Starting file-based enrichment tables cleanup', { patterns: patterns.map(p => p.source) }); try { // Fetch all enrichment tables const tables = await this.fetchEnrichmentTables(); testLogger.info('Fetched enrichment tables', { total: tables.length }); // Filter tables matching patterns const matchingTables = tables.filter(t => patterns.some(pattern => pattern.test(t.name)) ); testLogger.info('Found enrichment tables matching cleanup pattern', { count: matchingTables.length }); if (matchingTables.length === 0) { testLogger.info('No enrichment tables to clean up'); return; } // Delete each table let deletedCount = 0; let failedCount = 0; for (const table of matchingTables) { const result = await this.deleteEnrichmentTable(table.name); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted enrichment table', { name: table.name }); } else { failedCount++; testLogger.warn('Failed to delete enrichment table', { name: table.name, result }); } } testLogger.info('File-based enrichment tables cleanup completed', { total: matchingTables.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('File-based enrichment tables cleanup failed', { error: error.message }); } } /** * Fetch all URL-based enrichment tables from the status API * @returns {Promise>} Array of URL enrichment table names */ async fetchUrlEnrichmentTables() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/enrichment_tables/status`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { const errorBody = await response.text(); testLogger.error('Failed to fetch URL enrichment tables', { status: response.status, body: errorBody }); return []; } const data = await response.json(); // Response is a map where keys are table names // Guard against null/undefined response return Object.keys(data || {}); } catch (error) { testLogger.error('Failed to fetch URL enrichment tables', { error: error.message }); return []; } } /** * Clean up URL-based enrichment tables matching specified patterns * These are tables created via URL ingestion that show as "NaN MB" in the UI * @param {Array} patterns - Array of regex patterns to match table names */ async cleanupUrlEnrichmentTables(patterns = []) { testLogger.info('Starting URL enrichment tables cleanup', { patterns: patterns.map(p => p.source) }); try { // Fetch all URL-based enrichment tables const tableNames = await this.fetchUrlEnrichmentTables(); testLogger.info('Fetched URL enrichment tables', { total: tableNames.length }); // Filter tables matching patterns const matchingTables = tableNames.filter(name => patterns.some(pattern => pattern.test(name)) ); testLogger.info('Found URL enrichment tables matching cleanup pattern', { count: matchingTables.length }); if (matchingTables.length === 0) { testLogger.info('No URL enrichment tables to clean up'); return; } // Delete each table (uses same delete endpoint as file-based tables) let deletedCount = 0; let failedCount = 0; for (const tableName of matchingTables) { const result = await this.deleteEnrichmentTable(tableName); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted URL enrichment table', { name: tableName }); } else { failedCount++; testLogger.warn('Failed to delete URL enrichment table', { name: tableName, result }); } } testLogger.info('URL enrichment tables cleanup completed', { total: matchingTables.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('URL enrichment tables cleanup failed', { error: error.message }); } } /** * Clean up all pipelines for specific streams and matching patterns * Deletes pipelines where source.stream_name matches any of the target streams or patterns * @param {Array} streamNames - Array of stream names to match * @param {Array} sourceStreamPatterns - Array of regex patterns to match source stream names * @param {Array} pipelineNamePatterns - Array of regex patterns to match pipeline names */ async cleanupPipelines(streamNames = [], sourceStreamPatterns = [], pipelineNamePatterns = []) { testLogger.info('Starting pipeline cleanup', { streams: streamNames, sourceStreamPatterns: sourceStreamPatterns.map(p => p.toString()), pipelineNamePatterns: pipelineNamePatterns.map(p => p.toString()) }); try { // Fetch all pipelines const pipelines = await this.fetchPipelines(); testLogger.info('Fetched pipelines', { total: pipelines.length }); // Filter pipelines by source stream name OR by pattern matching const matchingPipelines = pipelines.filter(p => { if (!p.source) return false; // Exact match for stream names if (streamNames.length > 0 && streamNames.includes(p.source.stream_name)) return true; // Pattern match for source stream names if (sourceStreamPatterns.length > 0 && p.source.stream_name && sourceStreamPatterns.some(pattern => pattern.test(p.source.stream_name))) { return true; } // Pattern match for pipeline names if (pipelineNamePatterns.length > 0 && p.name && pipelineNamePatterns.some(pattern => pattern.test(p.name))) { return true; } return false; }); testLogger.info('Found pipelines matching target streams', { count: matchingPipelines.length }); if (matchingPipelines.length === 0) { testLogger.info('No pipelines to clean up'); return; } // Delete each pipeline let deletedCount = 0; let failedCount = 0; for (const pipeline of matchingPipelines) { const result = await this.deletePipeline(pipeline.pipeline_id); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted pipeline', { id: pipeline.pipeline_id, name: pipeline.name, stream: pipeline.source.stream_name }); } else { failedCount++; testLogger.warn('Failed to delete pipeline', { id: pipeline.pipeline_id, name: pipeline.name, stream: pipeline.source.stream_name, result }); } } testLogger.info('Pipeline cleanup completed', { total: matchingPipelines.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Pipeline cleanup failed', { error: error.message }); } } /** * Clean up all reports owned by the automation test user * Deletes all reports where owner matches ZO_ROOT_USER_EMAIL */ async cleanupReports() { testLogger.info('Starting reports cleanup', { owner: this.email }); try { // Fetch all reports const reports = await this.fetchReports(); testLogger.info('Fetched reports', { total: reports.length }); // Filter reports owned by automation user const ownedReports = reports.filter(r => r.owner === this.email); testLogger.info('Found reports owned by automation user', { count: ownedReports.length }); if (ownedReports.length === 0) { testLogger.info('No reports to clean up'); return; } // Delete each report let deletedCount = 0; let failedCount = 0; for (const report of ownedReports) { const result = await this.deleteReport(report.name); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted report', { id: report.report_id, name: report.name }); } else { failedCount++; testLogger.warn('Failed to delete report', { id: report.report_id, name: report.name, result }); } } testLogger.info('Reports cleanup completed', { total: ownedReports.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Reports cleanup failed', { error: error.message }); } } /** * Fetch all report folders (type=reports) * @returns {Promise} Array of folder objects with folderId and name */ async fetchReportFolders() { try { const response = await this._fetch(`${this.baseUrl}/api/v2/${this.org}/folders/reports`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch report folders', { status: response.status }); return []; } const data = await response.json(); return data.list || []; } catch (error) { testLogger.error('Failed to fetch report folders', { error: error.message }); return []; } } /** * Delete a report folder by ID * @param {string} folderId - The folder ID to delete * @returns {Promise} Deletion result */ async deleteReportFolder(folderId) { try { const response = await this._fetch(`${this.baseUrl}/api/v2/${this.org}/folders/reports/${folderId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); const text = await response.text(); try { const jsonResult = JSON.parse(text); return { code: response.ok ? 200 : response.status, message: jsonResult.message || text }; } catch { return { code: response.status, message: text }; } } catch (error) { testLogger.error('Failed to delete report folder', { folderId, error: error.message }); return { code: 500, message: error.message, error: error.message }; } } /** * Clean up report folders matching specified name prefixes * @param {Array} namePrefixes - Array of folder name prefixes to match (e.g., ['test_folder_', 'test_special_']) */ async cleanupReportFolders(namePrefixes = []) { testLogger.info('Starting report folders cleanup', { prefixes: namePrefixes }); try { const folders = await this.fetchReportFolders(); testLogger.info('Fetched report folders', { total: folders.length }); const matchingFolders = folders.filter(f => namePrefixes.some(prefix => f.name.startsWith(prefix)) ); testLogger.info('Found report folders matching prefixes', { count: matchingFolders.length }); if (matchingFolders.length === 0) { testLogger.info('No report folders to clean up'); return; } let deletedCount = 0; let failedCount = 0; for (const folder of matchingFolders) { const result = await this.deleteReportFolder(folder.folderId); if (result.code === 200) { deletedCount++; testLogger.info('Deleted report folder', { name: folder.name, folderId: folder.folderId }); } else { failedCount++; testLogger.warn('Failed to delete report folder', { name: folder.name, folderId: folder.folderId, result }); } } testLogger.info('Report folders cleanup completed', { deletedCount, failedCount }); if (failedCount > 0) { throw new Error(`Failed to delete ${failedCount} report folder(s)`); } } catch (error) { testLogger.error('Report folders cleanup failed', { error: error.message }); throw error; } } /** * Clean up all dashboards owned by the automation test user * Deletes dashboards from the 'default' folder where owner matches ZO_ROOT_USER_EMAIL */ async cleanupDashboards() { testLogger.info('Starting dashboard cleanup', { owner: this.email }); try { // Fetch dashboards from default folder const dashboards = await this.fetchDashboardsInFolder('default'); testLogger.info('Fetched dashboards', { total: dashboards.length }); // Filter dashboards owned by automation user const ownedDashboards = dashboards.filter(d => d.owner === this.email); testLogger.info('Found dashboards owned by automation user', { count: ownedDashboards.length }); if (ownedDashboards.length === 0) { testLogger.info('No dashboards to clean up'); return; } // Delete each dashboard let deletedCount = 0; let failedCount = 0; for (const dashboard of ownedDashboards) { const result = await this.deleteDashboard(dashboard.dashboard_id, dashboard.folder_id); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted dashboard', { id: dashboard.dashboard_id, title: dashboard.title }); } else { failedCount++; testLogger.warn('Failed to delete dashboard', { id: dashboard.dashboard_id, title: dashboard.title, result }); } } testLogger.info('Dashboard cleanup completed', { total: ownedDashboards.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Dashboard cleanup failed', { error: error.message }); } } /** * Fetch all streams of the given type * @param {string} [streamType='logs'] - Stream type to list (e.g. 'logs', 'traces', 'metrics') * @returns {Promise} Array of stream objects */ async fetchStreams(streamType = 'logs') { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/streams?type=${streamType}`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch streams', { status: response.status }); return []; } const data = await response.json(); return data.list || []; } catch (error) { testLogger.error('Failed to fetch streams', { error: error.message }); return []; } } /** * Delete a single stream * @param {string} streamName - The stream name * @param {string} [streamType='logs'] - Stream type to delete (e.g. 'logs', 'traces', 'metrics') * @returns {Promise} Deletion result */ async deleteStream(streamName, streamType = 'logs') { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/streams/${streamName}?type=${streamType}&delete_all=true`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete stream', { streamName, status: response.status }); return { code: response.status, message: 'Failed to delete stream' }; } const result = await response.json(); return result; } catch (error) { testLogger.error('Failed to delete stream', { streamName, error: error.message }); return { code: 500, error: error.message }; } } /** * Check if a stream is still being deleted or exists * Two-phase check: * 1. GET stream settings - checks if schema exists * 2. If schema gone, PUT settings to check if deletion marker is still active * @param {string} streamName - The stream name to check * @param {string} [streamType='logs'] - Stream type to check (e.g. 'logs', 'traces', 'metrics') * @returns {Promise} True if stream still exists or is being deleted */ async isStreamStillDeleting(streamName, streamType = 'logs') { try { // Phase 1: Check if stream schema exists via GET const getResponse = await this._fetch( `${this.baseUrl}/api/${this.org}/streams/${streamName}/settings?type=${streamType}`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } } ); // If we get 200, stream still exists if (getResponse.ok) { testLogger.debug('Stream schema still exists', { streamName }); return true; } // Check GET response for "is being deleted" error const getResult = await getResponse.json().catch(() => ({})); if (getResult.message && getResult.message.includes('is being deleted')) { testLogger.debug('Stream is being deleted (GET check)', { streamName }); return true; } // Phase 2: Schema is gone (404), but deletion marker might still be active // Try PUT settings - this triggers is_deleting_stream check without creating data const putResponse = await this._fetch( `${this.baseUrl}/api/${this.org}/streams/${streamName}/settings?type=${streamType}`, { method: 'PUT', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify({}) // Empty settings - won't create anything } ); const putResult = await putResponse.json().catch(() => ({})); // Check if deletion marker is still active if (putResult.message && putResult.message.includes('is being deleted')) { testLogger.debug('Stream deletion marker still active (PUT check)', { streamName }); return true; } // Both schema gone and no deletion marker - deletion complete testLogger.debug('Stream deletion fully complete', { streamName, getStatus: getResponse.status, putStatus: putResponse.status }); return false; } catch (error) { // Network error - assume deletion might still be in progress testLogger.warn('Error checking stream deletion status', { streamName, error: error.message }); return true; } } /** * Wait for a stream deletion to fully complete * @param {string} streamName - The stream name to wait for * @param {number} maxWaitMs - Maximum time to wait in milliseconds (default: 120000 = 2 minutes) * @param {number} pollIntervalMs - Polling interval in milliseconds (default: 3000 = 3 seconds) * @param {string} [streamType='logs'] - Stream type to poll (e.g. 'logs', 'traces', 'metrics') * @returns {Promise} True if deletion completed, false if timed out */ async waitForStreamDeletion(streamName, maxWaitMs = 120000, pollIntervalMs = 3000, streamType = 'logs') { const startTime = Date.now(); let attempts = 0; while (Date.now() - startTime < maxWaitMs) { attempts++; const stillDeleting = await this.isStreamStillDeleting(streamName, streamType); if (!stillDeleting) { testLogger.debug('Stream deletion confirmed complete', { streamName, attempts, elapsedMs: Date.now() - startTime }); return true; } testLogger.debug('Stream still being deleted, waiting...', { streamName, attempts, elapsedMs: Date.now() - startTime }); await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); } testLogger.warn('Timeout waiting for stream deletion', { streamName, maxWaitMs, attempts }); return false; } /** * Wait for specific stream names to be ready (not in "being deleted" state) * Use this to ensure streams are available before tests, even if they weren't found in the stream list * @param {Array} streamNames - Array of stream names to check * @param {number} maxWaitMs - Maximum time to wait per stream in ms (default: 120000) * @returns {Promise<{ready: number, blocked: string[]}>} Count of ready streams and list of blocked stream names */ async waitForStreamsReady(streamNames = [], maxWaitMs = 120000) { testLogger.info('Waiting for streams to be ready (not being deleted)...', { streamCount: streamNames.length, maxWaitMs }); let readyCount = 0; const blockedStreams = []; // Process in parallel batches const batchSize = 5; for (let i = 0; i < streamNames.length; i += batchSize) { const batch = streamNames.slice(i, i + batchSize); const results = await Promise.all( batch.map(async (streamName) => { const ready = await this.waitForStreamDeletion(streamName, maxWaitMs); return { streamName, ready }; }) ); results.forEach(({ streamName, ready }) => { if (ready) { readyCount++; } else { blockedStreams.push(streamName); testLogger.error('Stream still blocked (being deleted)', { streamName }); } }); } testLogger.info('Streams ready check complete', { total: streamNames.length, ready: readyCount, blocked: blockedStreams.length, blockedNames: blockedStreams }); if (blockedStreams.length > 0) { throw new Error(`${blockedStreams.length} stream(s) still being deleted: ${blockedStreams.join(', ')}`); } return { ready: readyCount, blocked: blockedStreams }; } /** * Fetch all metrics streams * @returns {Promise} Array of metrics stream objects */ async fetchMetricsStreams() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/streams?type=metrics`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch metrics streams', { status: response.status }); return []; } const data = await response.json(); return data.list || []; } catch (error) { testLogger.error('Failed to fetch metrics streams', { error: error.message }); return []; } } /** * Delete a single metrics stream * @param {string} streamName - The metrics stream name * @returns {Promise} Deletion result */ async deleteMetricsStream(streamName) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/streams/${streamName}?type=metrics&delete_all=true`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete metrics stream', { streamName, status: response.status }); return { code: response.status, message: 'Failed to delete metrics stream' }; } const result = await response.json(); // Ensure success response has code: 200 for consistency return { code: 200, ...result }; } catch (error) { testLogger.error('Failed to delete metrics stream', { streamName, error: error.message }); return { code: 500, message: error.message }; } } /** * Clean up metrics streams matching specified patterns * @param {Array} patterns - Array of regex patterns to match metrics stream names * @param {Array} protectedStreams - Array of metrics stream names to never delete (optional, e.g., 'default') */ async cleanupMetricsStreams(patterns = [], protectedStreams = ['default']) { testLogger.info('Starting metrics streams cleanup', { patterns: patterns.map(p => p.source), protectedStreams }); // Safety check: If no patterns provided, don't delete anything if (patterns.length === 0) { testLogger.info('No patterns provided for metrics cleanup - skipping'); return; } try { // Fetch all metrics streams const streams = await this.fetchMetricsStreams(); testLogger.info('Fetched metrics streams', { total: streams.length }); // Filter streams matching patterns but excluding protected streams const matchingStreams = streams.filter(s => patterns.some(pattern => pattern.test(s.name)) && !protectedStreams.includes(s.name) ); testLogger.info('Found metrics streams matching cleanup patterns', { count: matchingStreams.length }); if (matchingStreams.length === 0) { testLogger.info('No metrics streams to clean up'); return; } // Delete all matching metrics streams let deletedCount = 0; let failedCount = 0; for (const stream of matchingStreams) { const result = await this.deleteMetricsStream(stream.name); if (result.code === 200) { deletedCount++; testLogger.info('Deleted metrics stream', { name: stream.name }); } else { failedCount++; testLogger.warn('Failed to delete metrics stream', { name: stream.name, result }); } } testLogger.info('Metrics streams cleanup completed', { total: matchingStreams.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Metrics streams cleanup failed', { error: error.message }); } } /** * Clean up streams matching specified patterns * @param {Array} patterns - Array of regex patterns to match stream names * @param {Array} protectedStreams - Array of stream names to never delete (optional) * @param {Object} options - Optional configuration * @param {boolean} options.waitForDeletion - Whether to wait for deletions to complete (default: true) * @param {number} options.maxWaitPerStreamMs - Max wait time per stream in ms (default: 120000) * @param {string} [options.streamType='logs'] - Stream type to sweep (e.g. 'logs', 'traces', 'metrics') */ async cleanupStreams(patterns = [], protectedStreams = [], options = {}) { const { waitForDeletion = true, maxWaitPerStreamMs = 120000, streamType = 'logs' } = options; testLogger.info('Starting streams cleanup', { patterns: patterns.map(p => p.source), protectedStreams, waitForDeletion, streamType }); try { // Fetch all streams of the requested type (logs by default; e.g. traces for SDR trace streams) const streams = await this.fetchStreams(streamType); testLogger.info('Fetched streams', { total: streams.length }); // Filter streams matching patterns but excluding protected streams const matchingStreams = streams.filter(s => patterns.some(pattern => pattern.test(s.name)) && !protectedStreams.includes(s.name) ); testLogger.info('Found streams matching cleanup patterns', { count: matchingStreams.length }); if (matchingStreams.length === 0) { testLogger.info('No streams to clean up'); return; } // Phase 1: Initiate deletion for all streams let deletedCount = 0; let failedCount = 0; const streamsToWaitFor = []; for (const stream of matchingStreams) { const result = await this.deleteStream(stream.name, streamType); if (result.code === 200) { deletedCount++; streamsToWaitFor.push(stream.name); testLogger.debug('Initiated stream deletion', { name: stream.name }); } else { failedCount++; testLogger.warn('Failed to initiate stream deletion', { name: stream.name, result }); } } testLogger.info('Stream deletion initiated', { total: matchingStreams.length, initiated: deletedCount, failed: failedCount }); // Phase 2: Wait for all deletions to complete (if enabled) if (waitForDeletion && streamsToWaitFor.length > 0) { testLogger.info('Waiting for stream deletions to complete...', { streamCount: streamsToWaitFor.length, maxWaitPerStreamMs }); let completedCount = 0; let timedOutCount = 0; // Wait for streams in parallel batches to speed up the process const batchSize = 5; for (let i = 0; i < streamsToWaitFor.length; i += batchSize) { const batch = streamsToWaitFor.slice(i, i + batchSize); const results = await Promise.all( batch.map(streamName => this.waitForStreamDeletion(streamName, maxWaitPerStreamMs, 3000, streamType)) ); results.forEach((completed, index) => { if (completed) { completedCount++; } else { timedOutCount++; testLogger.error('Stream deletion timed out', { streamName: batch[index] }); } }); } testLogger.info('Stream deletion verification complete', { total: streamsToWaitFor.length, completed: completedCount, timedOut: timedOutCount }); if (timedOutCount > 0) { throw new Error(`${timedOutCount} stream(s) failed to complete deletion within timeout`); } } testLogger.info('Streams cleanup completed successfully', { total: matchingStreams.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Streams cleanup failed', { error: error.message }); throw error; // Re-throw to fail the cleanup test } } /** * Fetch all pipeline destinations * @returns {Promise} Array of pipeline destination objects */ async fetchPipelineDestinations() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/alerts/destinations?page_num=1&page_size=100000&sort_by=name&desc=false&module=pipeline`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch pipeline destinations', { status: response.status }); return []; } const destinations = await response.json(); return destinations; } catch (error) { testLogger.error('Failed to fetch pipeline destinations', { error: error.message }); return []; } } /** * Delete a pipeline destination * @param {string} name - Name of the destination to delete * @returns {Promise} Delete result with code and message */ async deletePipelineDestination(name) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/alerts/destinations/${name}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); const result = await response.json(); return { code: response.status, ...result }; } catch (error) { testLogger.error('Failed to delete pipeline destination', { name, error: error.message }); return { code: 500, message: error.message }; } } /** * Clean up pipeline destinations matching specified patterns * @param {Array} patterns - Array of regex patterns to match destination names */ async cleanupPipelineDestinations(patterns = []) { testLogger.info('Starting pipeline destinations cleanup', { patterns: patterns.map(p => p.source) }); try { // Fetch all pipeline destinations const destinations = await this.fetchPipelineDestinations(); testLogger.info('Fetched pipeline destinations', { total: destinations.length }); // Filter destinations matching patterns const matchingDestinations = destinations.filter(d => patterns.some(pattern => pattern.test(d.name)) ); testLogger.info('Found pipeline destinations matching cleanup pattern', { count: matchingDestinations.length }); if (matchingDestinations.length === 0) { testLogger.info('No pipeline destinations to clean up'); return; } // Delete each destination let deletedCount = 0; let failedCount = 0; for (const destination of matchingDestinations) { const result = await this.deletePipelineDestination(destination.name); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted pipeline destination', { name: destination.name }); } else { failedCount++; testLogger.warn('Failed to delete pipeline destination', { name: destination.name, result }); } } testLogger.info('Pipeline destinations cleanup completed', { total: matchingDestinations.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Pipeline destinations cleanup failed', { error: error.message }); } } /** * Fetch all service accounts * @returns {Promise} Array of service account objects */ async fetchServiceAccounts() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/service_accounts`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch service accounts', { status: response.status }); return []; } const data = await response.json(); return data.data || []; } catch (error) { testLogger.error('Failed to fetch service accounts', { error: error.message }); return []; } } /** * Delete a single service account * @param {string} email - The service account email * @returns {Promise} Deletion result */ async deleteServiceAccount(email) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/service_accounts/${email}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete service account', { email, status: response.status }); return { code: response.status, message: 'Failed to delete service account' }; } const result = await response.json(); return result; } catch (error) { testLogger.error('Failed to delete service account', { email, error: error.message }); return { code: 500, error: error.message }; } } /** * Clean up all test-created service accounts: * - legacy pattern "email*@gmail.com" (old email-based creation flow) * - current pattern "sax.*@sa.internal" (name-based flow: * uniqueSaName() in serviceAccount.spec.js + the synthesized * `.@sa.internal` identifier) */ async cleanupServiceAccounts() { testLogger.info('Starting service accounts cleanup'); try { // Fetch all service accounts const serviceAccounts = await this.fetchServiceAccounts(); testLogger.info('Fetched service accounts', { total: serviceAccounts.length }); const pattern = /^email.*@gmail\.com$|^sa\d+x\d+\..*@sa\.internal$/; const matchingAccounts = serviceAccounts.filter(sa => pattern.test(sa.email)); testLogger.info('Found service accounts matching cleanup pattern', { count: matchingAccounts.length }); if (matchingAccounts.length === 0) { testLogger.info('No service accounts to clean up'); return; } // Delete each service account let deletedCount = 0; let failedCount = 0; for (const account of matchingAccounts) { const result = await this.deleteServiceAccount(account.email); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted service account', { email: account.email }); } else { failedCount++; testLogger.warn('Failed to delete service account', { email: account.email, result }); } } testLogger.info('Service accounts cleanup completed', { total: matchingAccounts.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Service accounts cleanup failed', { error: error.message }); } } /** * Fetch all users * @returns {Promise} Array of user objects */ async fetchUsers() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/users`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch users', { status: response.status }); return []; } const data = await response.json(); return data.data || []; } catch (error) { testLogger.error('Failed to fetch users', { error: error.message }); return []; } } /** * Delete a single user * @param {string} email - The user email * @returns {Promise} Deletion result */ async deleteUser(email) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/users/${email}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete user', { email, status: response.status }); return { code: response.status, message: 'Failed to delete user' }; } const result = await response.json(); return result; } catch (error) { testLogger.error('Failed to delete user', { email, error: error.message }); return { code: 500, error: error.message }; } } /** * Clean up all users matching patterns "email*@gmail.com" and "duplicate*@gmail.com" * Deletes users with emails starting with "email" or "duplicate" and ending with "@gmail.com" */ async cleanupUsers() { testLogger.info('Starting users cleanup'); try { // Fetch all users const users = await this.fetchUsers(); testLogger.info('Fetched users', { total: users.length }); // Filter users matching patterns: starts with "email" or "duplicate" and ends with "@gmail.com" const emailPattern = /^email.*@gmail\.com$/; const duplicatePattern = /^duplicate.*@gmail\.com$/; const matchingUsers = users.filter(user => emailPattern.test(user.email) || duplicatePattern.test(user.email) ); testLogger.info('Found users matching cleanup patterns', { count: matchingUsers.length }); if (matchingUsers.length === 0) { testLogger.info('No users to clean up'); return; } // Delete each user let deletedCount = 0; let failedCount = 0; for (const user of matchingUsers) { const result = await this.deleteUser(user.email); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted user', { email: user.email }); } else { failedCount++; testLogger.warn('Failed to delete user', { email: user.email, result }); } } testLogger.info('Users cleanup completed', { total: matchingUsers.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Users cleanup failed', { error: error.message }); } } /** * Clean up regex patterns that match test data * Deletes patterns whose names appear in test data files: * - tests/test-data/regex_patterns_import.json * - tests/test-data/sdr_test_data.json */ async cleanupRegexPatterns() { testLogger.info('Starting regex patterns cleanup'); try { // Fetch all regex patterns const response = await this._fetch(`${this.baseUrl}/api/${this.org}/re_patterns`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); // Handle 404 or other errors gracefully (feature may not be available in OSS) if (!response.ok) { if (response.status === 404) { testLogger.info('Regex patterns endpoint not available (OSS edition or feature not enabled)'); } else { testLogger.warn('Failed to fetch regex patterns', { status: response.status }); } return; } const data = await response.json(); const patterns = data.patterns || []; testLogger.info('Fetched regex patterns', { total: patterns.length }); if (patterns.length === 0) { testLogger.info('No regex patterns found'); return; } // Load test data pattern names const regexPatternsImport = require('../../test-data/regex_patterns_import.json'); const sdrTestData = require('../../test-data/sdr_test_data.json'); // Collect all pattern names from test data const testPatternNames = new Set(); // From regex_patterns_import.json regexPatternsImport.forEach(pattern => { testPatternNames.add(pattern.name); }); // From sdr_test_data.json if (sdrTestData.regexPatterns) { sdrTestData.regexPatterns.forEach(pattern => { testPatternNames.add(pattern.name); }); } testLogger.info('Loaded test pattern names', { count: testPatternNames.size }); // Additional test prefixes that should always be cleaned up const testPrefixes = [ 'duplicate_test_', // regexPatternManagement.spec.js 'log_filename_', // multipleSDRPatterns.spec.js 'time_hh_mm_ss_', // multipleSDRPatterns.spec.js 'ifsc_code_', // multipleSDRPatterns.spec.js 'date_dd_mm_yyyy_', // multipleSDRPatterns.spec.js 'email_format_', // SDR tests 'us_phone_', // SDR tests 'credit_card_', // SDR tests 'ssn_', // SDR tests 'traces_' // Traces SDR tests (traces___ patterns) ]; // Filter patterns that match test data names (using prefix matching to catch patterns with unique suffixes) const basePatternNames = Array.from(testPatternNames); const matchingPatterns = patterns.filter(pattern => basePatternNames.some(baseName => pattern.name.startsWith(baseName)) || testPrefixes.some(prefix => pattern.name.startsWith(prefix)) ); testLogger.info('Found patterns matching test data (prefix match)', { count: matchingPatterns.length }); if (matchingPatterns.length === 0) { testLogger.info('No regex patterns to clean up'); return; } // Delete each matching pattern let deletedCount = 0; let failedCount = 0; for (const pattern of matchingPatterns) { try { const deleteResponse = await this._fetch(`${this.baseUrl}/api/${this.org}/re_patterns/${pattern.id}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (deleteResponse.ok) { deletedCount++; testLogger.debug('Deleted regex pattern', { name: pattern.name, id: pattern.id }); } else { failedCount++; testLogger.warn('Failed to delete regex pattern', { name: pattern.name, id: pattern.id, status: deleteResponse.status }); } } catch (error) { failedCount++; testLogger.error('Error deleting regex pattern', { name: pattern.name, id: pattern.id, error: error.message }); } } testLogger.info('Regex patterns cleanup completed', { total: matchingPatterns.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Regex patterns cleanup failed', { error: error.message }); } } /** * Clean up custom logo from _meta organization * Deletes the custom logo via DELETE API call */ async cleanupLogo() { testLogger.info('Starting logo cleanup'); try { const response = await this._fetch(`${this.baseUrl}/api/_meta/settings/logo`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete logo', { status: response.status }); return { successful: 'false', status: response.status }; } const result = await response.json(); if (result.successful === 'true') { testLogger.info('Logo cleanup completed successfully'); } else { testLogger.warn('Logo cleanup returned unexpected result', { result }); } return result; } catch (error) { testLogger.error('Logo cleanup failed', { error: error.message }); return { successful: 'false', error: error.message }; } } /** * Fetch all search jobs * @returns {Promise} Array of search job objects */ async fetchSearchJobs() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/search_jobs?type=logs`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch search jobs', { status: response.status }); return []; } const searchJobs = await response.json(); return searchJobs || []; } catch (error) { testLogger.error('Failed to fetch search jobs', { error: error.message }); return []; } } /** * Delete a single search job * @param {string} jobId - The search job ID * @returns {Promise} Deletion result */ async deleteSearchJob(jobId) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/search_jobs/${jobId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete search job', { jobId, status: response.status }); return { code: response.status, message: 'Failed to delete search job' }; } const result = await response.json(); return { code: 200, ...result }; } catch (error) { testLogger.error('Failed to delete search job', { jobId, error: error.message }); return { code: 500, error: error.message }; } } /** * Clean up all search jobs * Deletes all search jobs for the organization */ async cleanupSearchJobs() { testLogger.info('Starting search jobs cleanup'); try { // Fetch all search jobs const searchJobs = await this.fetchSearchJobs(); testLogger.info('Fetched search jobs', { total: searchJobs.length }); if (searchJobs.length === 0) { testLogger.info('No search jobs to clean up'); return; } // Delete each search job let deletedCount = 0; let failedCount = 0; for (const job of searchJobs) { const result = await this.deleteSearchJob(job.id); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted search job', { jobId: job.id, userId: job.user_id }); } else { failedCount++; testLogger.warn('Failed to delete search job', { jobId: job.id, userId: job.user_id, result }); } } testLogger.info('Search jobs cleanup completed', { total: searchJobs.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Search jobs cleanup failed', { error: error.message }); } } /** * Fetch all saved views * @returns {Promise} Array of saved view objects */ async fetchSavedViews() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/savedviews`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch saved views', { status: response.status }); return []; } const data = await response.json(); return data.views || []; } catch (error) { testLogger.error('Failed to fetch saved views', { error: error.message }); return []; } } /** * Delete a single saved view * @param {string} viewId - The saved view ID * @returns {Promise} Deletion result */ async deleteSavedView(viewId) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/savedviews/${viewId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete saved view', { viewId, status: response.status }); return { code: response.status, message: 'Failed to delete saved view' }; } const result = await response.json(); return { code: 200, ...result }; } catch (error) { testLogger.error('Failed to delete saved view', { viewId, error: error.message }); return { code: 500, error: error.message }; } } /** * Clean up all saved views matching test patterns * Deletes saved views starting with "streamslog" or "multistream_view_" */ async cleanupSavedViews() { testLogger.info('Starting saved views cleanup'); try { // Fetch all saved views const savedViews = await this.fetchSavedViews(); testLogger.info('Fetched saved views', { total: savedViews.length }); // Filter saved views matching patterns: starts with "streamslog" or "multistream_view_" const matchingSavedViews = savedViews.filter(view => view.view_name.startsWith('streamslog') || view.view_name.startsWith('multistream_view_') ); testLogger.info('Found saved views matching cleanup patterns', { count: matchingSavedViews.length }); if (matchingSavedViews.length === 0) { testLogger.info('No saved views to clean up'); return; } // Delete each saved view let deletedCount = 0; let failedCount = 0; for (const view of matchingSavedViews) { const result = await this.deleteSavedView(view.view_id); if (result.code === 200) { deletedCount++; testLogger.debug('Deleted saved view', { viewId: view.view_id, viewName: view.view_name }); } else { failedCount++; testLogger.warn('Failed to delete saved view', { viewId: view.view_id, viewName: view.view_name, result }); } } testLogger.info('Saved views cleanup completed', { total: matchingSavedViews.length, deleted: deletedCount, failed: failedCount }); } catch (error) { testLogger.error('Saved views cleanup failed', { error: error.message }); } } // ============================================================ // WORKFLOWS (v1) cleanup — Enterprise "Workflows" feature. // A workflow linked to an alert is delete-protected, so callers must delete // the linked test alerts FIRST (completeCascadeCleanup does this in STEP 1), // then call cleanupWorkflows() to remove the now-unlinked workflows. // ============================================================ /** Fetch all workflows in the current org. Returns an array (list endpoint returns a bare array). */ async fetchWorkflows() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/workflows`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to fetch workflows', { status: response.status }); return []; } const data = await response.json(); return Array.isArray(data) ? data : (data.list || data.data || []); } catch (error) { testLogger.error('Failed to fetch workflows', { error: error.message }); return []; } } /** Delete a single workflow by id. */ async deleteWorkflow(workflowId) { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/workflows/${workflowId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); const result = await response.json().catch(() => ({})); return { code: response.status, ...result }; } catch (error) { testLogger.error('Failed to delete workflow', { workflowId, error: error.message }); return { code: 500, error: error.message }; } } /** * Delete all workflows whose name matches any of the given prefixes/regexes. * Safe to call as a standalone sweep; linked workflows will report an error * (delete the referencing alerts first — see completeCascadeCleanup STEP 1). * @param {Array} patterns */ async cleanupWorkflows(patterns = []) { if (!patterns.length) return; testLogger.info('Starting workflows cleanup', { patterns: patterns.map(p => p.source || p) }); try { const workflows = await this.fetchWorkflows(); const matching = workflows.filter(w => patterns.some(p => (p instanceof RegExp) ? p.test(w.name) : String(w.name).startsWith(p)) ); testLogger.info('Workflows matching cleanup patterns', { total: workflows.length, matching: matching.length }); let deleted = 0, failed = 0; for (const w of matching) { const res = await this.deleteWorkflow(w.id); if (res.code === 200) { deleted++; testLogger.debug('Deleted workflow', { id: w.id, name: w.name }); } else { failed++; testLogger.warn('Failed to delete workflow (still linked to an alert?)', { id: w.id, name: w.name, res }); } } testLogger.info('Workflows cleanup completed', { deleted, failed }); } catch (error) { testLogger.error('Failed to cleanup workflows', { error: error.message }); } } /** * Complete cascade cleanup: Alerts -> Folders -> Destinations -> Templates * Deletes resources in correct dependency order to avoid conflicts * @param {Array} destinationPrefixes - Array of destination name prefixes or regex patterns to match (e.g., ['auto_', /^destination\d{1,3}$/]) * @param {Array} templatePrefixes - Array of template name prefixes to match (e.g., ['auto_email_template_', 'auto_webhook_template_']) * @param {Array} folderPrefixes - Array of folder name prefixes to match (e.g., ['auto_']) * @param {Array} workflowPrefixes - Array of workflow name prefixes/regexes to match (deleted AFTER alerts are unlinked) */ async completeCascadeCleanup(destinationPrefixes = [], templatePrefixes = [], folderPrefixes = [], workflowPrefixes = ['wf_auto_']) { testLogger.info('Starting complete cascade cleanup (Alerts -> Folders -> Destinations -> Templates)', { destinationPrefixes, templatePrefixes, folderPrefixes }); // Alert name prefixes to match - alerts with these prefixes will be deleted from ANY folder // This is derived from destination prefixes since alerts typically use similar naming const alertPrefixes = [ 'auto_', 'Automation_', 'sanity', 'rbac_', 'wf_auto_', // Workflows v1 test alerts (unlink workflows before deleting them) 'user_delete_test_', // RBAC user delete test alerts (orphaned) 'user_update_test_', // RBAC user update test alerts (orphaned) 'viewer_delete_test_', // RBAC viewer delete test alerts (orphaned) 'viewer_update_test_', // RBAC viewer update test alerts (orphaned) 'editor_create_test_', // RBAC editor create test alerts (orphaned) 'editor_delete_test_', // RBAC editor delete test alerts (orphaned) 'pw_lib_' // alert-library.spec.js (Alert Library e2e installed alerts) ]; try { // ============================================ // STEP 1: Delete all ALERTS matching prefixes from ALL folders // (Alerts depend on destinations, so delete first) // ============================================ let deletedAlerts = 0; let deletedFolders = 0; testLogger.info('Step 1: Deleting alerts matching prefixes from ALL folders', { alertPrefixes }); const allFolders = await this.fetchAlertFolders(); testLogger.info('Fetched all alert folders', { total: allFolders.length }); for (const folder of allFolders) { // Fetch all alerts in this folder const alerts = await this.fetchAlertsInFolder(folder.folderId); if (alerts.length === 0) continue; // Filter alerts matching our prefixes const matchingAlerts = alerts.filter(alert => alertPrefixes.some(prefix => alert.name.startsWith(prefix)) ); if (matchingAlerts.length > 0) { testLogger.info('Found matching alerts in folder', { folderId: folder.folderId, folderName: folder.name, totalAlerts: alerts.length, matchingAlerts: matchingAlerts.length }); for (const alert of matchingAlerts) { const alertDeleteResult = await this.deleteAlert(alert.alert_id, folder.folderId); if (alertDeleteResult.code === 200) { deletedAlerts++; testLogger.debug('Deleted alert', { alertId: alert.alert_id, name: alert.name }); } else { testLogger.warn('Failed to delete alert', { alertId: alert.alert_id, name: alert.name, result: alertDeleteResult }); } } } } // ============================================ // STEP 2: Delete FOLDERS matching prefixes (after alerts are gone) // ============================================ if (folderPrefixes.length > 0) { testLogger.info('Step 2: Deleting folders matching prefixes', { prefixes: folderPrefixes }); // Re-fetch folders to get current state const currentFolders = await this.fetchAlertFolders(); const matchingFolders = currentFolders.filter(f => folderPrefixes.some(prefix => f.name.startsWith(prefix)) ); testLogger.info('Found folders matching prefixes', { total: matchingFolders.length }); for (const folder of matchingFolders) { // Delete any remaining alerts in the folder first const remainingAlerts = await this.fetchAlertsInFolder(folder.folderId); for (const alert of remainingAlerts) { await this.deleteAlert(alert.alert_id, folder.folderId); deletedAlerts++; } // Then delete the folder const folderDeleteResult = await this.deleteFolder(folder.folderId); if (folderDeleteResult.code === 200 || folderDeleteResult.message?.includes('Folder deleted')) { deletedFolders++; testLogger.info('Deleted folder', { folderId: folder.folderId, name: folder.name }); } else { testLogger.warn('Failed to delete folder', { folderId: folder.folderId, name: folder.name, result: folderDeleteResult }); } } } testLogger.info('Step 1-2 complete: Alerts and folders deleted', { deletedAlerts, deletedFolders }); // ============================================ // STEP 3: Delete DESTINATIONS matching prefixes // (Destinations depend on templates, delete before templates) // ============================================ let deletedDestinations = 0; if (destinationPrefixes.length > 0) { testLogger.info('Step 3: Deleting destinations matching prefixes/patterns', { prefixes: destinationPrefixes }); const { destinations } = await this.fetchDestinationsWithTemplateMapping(); const matchingDestinations = destinations.filter(d => destinationPrefixes.some(prefix => { if (prefix instanceof RegExp) { return prefix.test(d.name); } return d.name.startsWith(prefix); }) ); testLogger.info('Found destinations matching prefixes/patterns', { total: matchingDestinations.length }); for (const destination of matchingDestinations) { const deleteResult = await this._fetch(`${this.baseUrl}/api/${this.org}/alerts/destinations/${destination.name}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); const result = await deleteResult.json(); if (result.code === 200) { deletedDestinations++; testLogger.debug('Deleted destination', { name: destination.name }); } else if (result.code === 409) { // Destination still blocked by an alert - this shouldn't happen if alerts were deleted correctly testLogger.warn('Destination still blocked by alert (unexpected)', { name: destination.name, result: result }); } else { testLogger.warn('Failed to delete destination', { name: destination.name, result: result }); } } } testLogger.info('Step 3 complete: Destinations deleted', { deletedDestinations }); // ============================================ // STEP 4: Delete TEMPLATES matching prefixes // (Templates have no dependencies, delete last) // ============================================ let deletedTemplates = 0; if (templatePrefixes.length > 0) { testLogger.info('Step 4: Deleting templates matching prefixes', { prefixes: templatePrefixes }); const allTemplatesResponse = await this._fetch(`${this.baseUrl}/api/${this.org}/alerts/templates?page_num=1&page_size=100000&sort_by=name&desc=false`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (allTemplatesResponse.ok) { const allTemplates = await allTemplatesResponse.json(); const matchingTemplates = allTemplates.filter(t => templatePrefixes.some(prefix => t.name.startsWith(prefix)) ); testLogger.info('Found templates matching prefixes', { total: matchingTemplates.length }); for (const template of matchingTemplates) { const templateDeleteResult = await this._fetch(`${this.baseUrl}/api/${this.org}/alerts/templates/${template.name}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); const templateResult = await templateDeleteResult.json(); if (templateResult.code === 200) { deletedTemplates++; testLogger.debug('Deleted template', { name: template.name }); } else if (templateResult.code === 409) { // Template still in use by a destination - this shouldn't happen if destinations were deleted testLogger.warn('Template still in use (unexpected)', { name: template.name, result: templateResult }); } else { testLogger.warn('Failed to delete template', { name: template.name, result: templateResult }); } } } } testLogger.info('Step 4 complete: Templates deleted', { deletedTemplates }); // STEP 5: Delete WORKFLOWS (alerts were unlinked in STEP 1, so these are now deletable) if (workflowPrefixes && workflowPrefixes.length) { testLogger.info('Step 5: Deleting workflows matching prefixes', { workflowPrefixes: workflowPrefixes.map(p => p.source || p) }); await this.cleanupWorkflows(workflowPrefixes); } testLogger.info('Complete cascade cleanup finished', { deletedAlerts, deletedFolders, deletedDestinations, deletedTemplates }); } catch (error) { testLogger.error('Complete cascade cleanup failed', { error: error.message }); } } /** * ========================================== * PIPELINE-SPECIFIC API METHODS * ========================================== */ /** * Clean conditions for API (removes UI-only fields) * This removes fields like 'id' and 'groupId' that are only used in UI * @param {object} conditions - Condition configuration object with UI fields * @returns {object} Cleaned conditions for API */ cleanConditionsForAPI(conditions) { if (conditions.filterType === "condition") { // Remove UI-only fields: id, groupId // KEEP values field as it's required by the backend const { id, groupId, ...cleaned } = conditions; return cleaned; } else if (conditions.filterType === "group") { // Recursively clean nested conditions const { id, groupId, ...cleaned } = conditions; cleaned.conditions = conditions.conditions.map(c => this.cleanConditionsForAPI(c)); return cleaned; } return conditions; } /** * Create a stream via API * @param {string} streamName - Name of the stream to create * @param {string} streamType - Type of stream (logs, metrics, traces) * @returns {Promise} API response */ async createStream(streamName, streamType = 'logs') { const payload = { fields: [], settings: { partition_keys: [], index_fields: [], full_text_search_keys: [], bloom_filter_fields: [], defined_schema_fields: [], data_retention: 14 } }; testLogger.info('Creating stream via API', { streamName, streamType }); try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/streams/${streamName}?type=${streamType}`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); if (response.status === 200 && data.code === 200) { testLogger.info('Stream created successfully', { streamName }); } else { testLogger.warn('Stream creation returned non-200 or already exists', { streamName, status: response.status, data }); } return { status: response.status, data }; } catch (error) { testLogger.error('Failed to create stream', { streamName, error: error.message }); return { status: 500, error: error.message }; } } /** * Create a pipeline via API * @param {string} pipelineName - Name of the pipeline * @param {string} sourceStream - Source stream name * @param {string} destStream - Destination stream name * @param {object} conditions - Condition configuration object * @returns {Promise} API response */ async createPipeline(pipelineName, sourceStream, destStream, conditions) { // Clean conditions to remove UI-only fields const cleanedConditions = this.cleanConditionsForAPI(conditions); testLogger.info('Cleaned conditions', { cleaned: JSON.stringify(cleanedConditions, null, 2) }); // Generate unique node IDs const inputNodeId = `input-${Date.now()}`; const conditionNodeId = `condition-${Date.now()}`; const outputNodeId = `output-${Date.now()}`; const pipelinePayload = { pipeline_id: "", version: 0, enabled: true, org: this.org, name: pipelineName, description: `Validation test pipeline for ${pipelineName}`, source: { source_type: "realtime" }, paused_at: null, nodes: [ { id: inputNodeId, position: { x: 100, y: 100 }, data: { node_type: "stream", stream_type: "logs", stream_name: sourceStream, org_id: this.org }, io_type: "input" }, { id: conditionNodeId, position: { x: 300, y: 200 }, data: { node_type: "condition", version: 2, conditions: cleanedConditions }, io_type: "default" }, { id: outputNodeId, position: { x: 500, y: 300 }, data: { node_type: "stream", stream_type: "logs", stream_name: destStream, org_id: this.org }, io_type: "output" } ], edges: [ { id: `e-${inputNodeId}-${conditionNodeId}`, source: inputNodeId, target: conditionNodeId, markerEnd: { type: "arrowclosed", width: 20, height: 20 }, type: "custom", style: { strokeWidth: 2 }, animated: true, updatable: true }, { id: `e-${conditionNodeId}-${outputNodeId}`, source: conditionNodeId, target: outputNodeId, markerEnd: { type: "arrowclosed", width: 20, height: 20 }, type: "custom", style: { strokeWidth: 2 }, animated: true, updatable: true } ], type: "realtime", stream_name: sourceStream, stream_type: "logs" }; testLogger.info('Creating pipeline via API', { pipelineName, sourceStream, destStream }); testLogger.info('Pipeline payload', { payload: JSON.stringify(pipelinePayload, null, 2) }); try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/pipelines`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(pipelinePayload) }); const contentType = response.headers.get('content-type'); let data; if (contentType && contentType.includes('application/json')) { data = await response.json(); } else { const text = await response.text(); data = { error: text }; } testLogger.info('Pipeline creation response', { pipelineName, status: response.status, data }); if (response.status !== 200) { throw new Error(`Pipeline creation failed: ${JSON.stringify(data)}`); } return { status: response.status, data }; } catch (error) { testLogger.error('Failed to create pipeline', { pipelineName, error: error.message }); throw error; } } /** * Ingest data to a stream * Sends records individually to ensure uniqueness * @param {string} streamName - Stream name * @param {array} data - Array of log objects * @returns {Promise} Result with success/fail counts */ async ingestData(streamName, data) { testLogger.info('Ingesting data', { streamName, recordCount: data.length }); let successCount = 0; let failCount = 0; // Send records one by one to ensure each is treated as unique for (let i = 0; i < data.length; i++) { const record = data[i]; try { const response = await this._fetch(`${ingestBase(process.env.INGESTION_URL)}/api/${this.org}/${streamName}/_json`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify([record]) // Send as single-element array }); const responseData = await response.json(); if (response.status === 200 && responseData.code === 200) { successCount++; testLogger.debug(`Ingested record ${i+1}/${data.length}`, { name: record.name, test_id: record.test_id || 'no_id', unique_id: record.unique_id }); } else { failCount++; testLogger.error(`Failed to ingest record ${i+1}/${data.length}`, { response: responseData, test_id: record.test_id || record.name }); } // Small delay between records await new Promise(resolve => setTimeout(resolve, 500)); } catch (error) { failCount++; testLogger.error(`Error ingesting record ${i+1}/${data.length}`, { error: error.message }); } } testLogger.info('Ingestion complete', { streamName, total: data.length, success: successCount, failed: failCount }); if (failCount > 0) { throw new Error(`Failed to ingest ${failCount} out of ${data.length} records`); } return { total: data.length, success: successCount, failed: failCount }; } /** * Query stream via API * @param {string} streamName - Stream to query * @param {number} expectedMinCount - Minimum expected record count (optional) * @returns {Promise} Query results */ async queryStream(streamName, expectedMinCount = null) { testLogger.info('Querying stream via API', { streamName }); // Query for last 10 minutes const endTime = Date.now() * 1000; // microseconds const startTime = endTime - (10 * 60 * 1000 * 1000); const query = { query: { sql: `SELECT * FROM "${streamName}"`, start_time: startTime, end_time: endTime, from: 0, size: 1000 } }; try { const response = await this._fetch(`${process.env.INGESTION_URL}/api/${this.org}/_search?type=logs`, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, body: JSON.stringify(query) }); const data = await response.json(); const results = data.hits || []; testLogger.info('Query results', { streamName, recordCount: results.length }); if (expectedMinCount !== null && expectedMinCount !== 0) { if (results.length < expectedMinCount) { throw new Error(`Expected at least ${expectedMinCount} records, got ${results.length}`); } } return results; } catch (error) { testLogger.error('Failed to query stream', { streamName, error: error.message }); throw error; } } /** * ========================================== * SDR (Sensitive Data Redaction) CLEANUP METHODS * ========================================== */ /** * Fetch all regex patterns via API * @returns {Promise} Array of pattern objects with id, name, pattern, description */ async fetchRegexPatterns() { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/re_patterns`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { if (response.status === 404) { testLogger.info('Regex patterns endpoint not available (OSS edition)'); return []; } testLogger.error('Failed to fetch regex patterns', { status: response.status }); return []; } const data = await response.json(); return data.patterns || []; } catch (error) { testLogger.error('Failed to fetch regex patterns', { error: error.message }); return []; } } /** * Delete a single regex pattern by ID * @param {string} patternId - The pattern ID * @param {string} patternName - The pattern name (for logging) * @returns {Promise} Deletion result */ async deleteRegexPatternById(patternId, patternName = '') { try { const response = await this._fetch(`${this.baseUrl}/api/${this.org}/re_patterns/${patternId}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); if (!response.ok) { testLogger.error('Failed to delete regex pattern', { patternId, patternName, status: response.status }); return { code: response.status, message: 'Failed to delete regex pattern' }; } const result = await response.json(); return { code: 200, ...result }; } catch (error) { testLogger.error('Failed to delete regex pattern', { patternId, patternName, error: error.message }); return { code: 500, error: error.message }; } } /** * Delete regex patterns matching specified prefixes * @param {Array} prefixes - Array of pattern name prefixes to match (e.g., ['url_format_hash', 'strong_password_hash']) * @returns {Promise<{deleted: number, failed: number}>} Deletion result counts */ async deleteRegexPatternsByPrefix(prefixes = []) { testLogger.info('Deleting regex patterns by prefix', { prefixes }); try { const patterns = await this.fetchRegexPatterns(); testLogger.info('Fetched regex patterns', { total: patterns.length }); // Filter patterns matching prefixes const matchingPatterns = patterns.filter(p => prefixes.some(prefix => p.name.startsWith(prefix) || p.name === prefix) ); testLogger.info('Found patterns matching prefixes', { count: matchingPatterns.length }); let deletedCount = 0; let failedCount = 0; for (const pattern of matchingPatterns) { const result = await this.deleteRegexPatternById(pattern.id, pattern.name); if (result.code === 200) { deletedCount++; testLogger.info('Deleted regex pattern', { name: pattern.name, id: pattern.id }); } else { failedCount++; testLogger.warn('Failed to delete regex pattern', { name: pattern.name, id: pattern.id, result }); } } testLogger.info('Regex patterns deletion completed', { total: matchingPatterns.length, deleted: deletedCount, failed: failedCount }); return { deleted: deletedCount, failed: failedCount }; } catch (error) { testLogger.error('Regex patterns deletion failed', { error: error.message }); return { deleted: 0, failed: 0 }; } } /** * Complete SDR test cleanup - deletes streams first (which removes pattern associations), then patterns * This should be called in beforeAll hook of SDR tests * @param {Array} streamNames - Exact stream names to delete (e.g., ['sdr_combined_hash_test']) * @param {Array} patternPrefixes - Pattern name prefixes to delete (e.g., ['url_format_hash', 'strong_password_hash']) * @param {Object} options - Optional configuration * @param {boolean} options.waitForDeletion - Whether to wait for stream deletions to complete (default: true) */ async cleanupSDRTestData(streamNames = [], patternPrefixes = [], options = {}) { const { waitForDeletion = true } = options; testLogger.info('=== Starting SDR Test Cleanup via API ===', { streams: streamNames, patternPrefixes }); try { // Step 1: Delete streams first (this automatically unlinks any pattern associations) if (streamNames.length > 0) { testLogger.info('Step 1: Deleting SDR test streams'); for (const streamName of streamNames) { testLogger.info(`Attempting to delete stream: ${streamName}`); const result = await this.deleteStream(streamName); if (result.code === 200) { testLogger.info(`✓ Stream deletion initiated: ${streamName}`); } else if (result.code === 404) { testLogger.info(`✓ Stream does not exist (OK): ${streamName}`); } else { testLogger.warn(`Failed to delete stream: ${streamName}`, { result }); } } // Wait for stream deletions to complete if enabled if (waitForDeletion) { testLogger.info('Waiting for stream deletions to complete...'); for (const streamName of streamNames) { const completed = await this.waitForStreamDeletion(streamName, 60000, 2000); if (completed) { testLogger.info(`✓ Stream deletion complete: ${streamName}`); } else { testLogger.warn(`Stream deletion may still be in progress: ${streamName}`); } } } } // Step 2: Delete patterns by prefix (they should now be unlinked from streams) if (patternPrefixes.length > 0) { testLogger.info('Step 2: Deleting SDR test patterns'); const patternResult = await this.deleteRegexPatternsByPrefix(patternPrefixes); testLogger.info('Pattern deletion result', patternResult); } // Wait additional time to ensure backend fully processes deletions // This prevents "stream is being deleted" errors when tests immediately try to recreate streams testLogger.info('Waiting additional time for backend to finalize deletions...'); await new Promise(resolve => setTimeout(resolve, 5000)); testLogger.info('=== SDR Test Cleanup Complete ==='); } catch (error) { testLogger.error('SDR Test Cleanup failed', { error: error.message }); // Don't throw - allow test to continue even if cleanup fails } } /** * IAM leftovers a CRASHED run orphaned: roles, groups, users and service accounts * under `prefix`. * * The ONLY prefix sweep for IAM. Each IAM spec deletes exactly what it created * (makeTracker in playwright-tests/IAM/iam-fixtures.js); doing a prefix sweep in * every spec's beforeAll is what had them deleting each other's fixtures mid-test. * Recovery belongs here, once, before any suite starts. * * Order matters: a group pins the roles it holds, so groups go first. * Enterprise-only — on an OSS build /roles answers 4xx and this returns quietly, * because every other suite depends on cleanup succeeding. */ async cleanupIamArtifacts(prefix = 'ui_auto') { testLogger.info('Starting IAM artifact cleanup', { prefix }); const headers = { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }; const api = (path) => `${this.baseUrl}/api/${this.org}${path}`; const removed = { groups: [], roles: [], users: [] }; try { const probe = await this._fetch(api('/roles'), { method: 'GET', headers }); if (!probe.ok) { testLogger.info('IAM cleanup skipped — roles API unavailable (RBAC off / OSS build)', { status: probe.status, }); return removed; } const json = async (res) => { try { return await res.json(); } catch { return null; } }; const del = async (path) => { try { await this._fetch(api(path), { method: 'DELETE', headers }); } catch { /* best effort */ } }; for (const g of (await json(await this._fetch(api('/groups'), { method: 'GET', headers }))) || []) { if (typeof g === 'string' && g.startsWith(prefix)) { await del(`/groups/${g}`); removed.groups.push(g); } } for (const r of (await json(probe)) || []) { if (typeof r === 'string' && r.startsWith(prefix)) { await del(`/roles/${r}`); removed.roles.push(r); } } const users = (await json(await this._fetch(api('/users'), { method: 'GET', headers })))?.data ?? []; for (const u of users) { if (typeof u?.email === 'string' && u.email.startsWith(prefix)) { await del(`/users/${u.email}`); removed.users.push(u.email); } } const sas = (await json(await this._fetch(api('/service_accounts'), { method: 'GET', headers })))?.data ?? []; for (const sa of sas) { if (typeof sa?.email === 'string' && sa.email.startsWith(prefix) && !sa.is_system) { await del(`/service_accounts/${sa.email}`); removed.users.push(sa.email); } } testLogger.info('IAM artifact cleanup complete', { groups: removed.groups.length, roles: removed.roles.length, users: removed.users.length, }); } catch (e) { // Never fail the cleanup run: every other suite is waiting on it. testLogger.warn('IAM artifact cleanup hit an error — continuing', { error: e?.message }); } return removed; } /** Crashed-run fleet leftovers; quiet on OSS, where the fleet routes are not registered and answer 404. */ async cleanupFleetArtifacts(fleetPrefixes = ['fl_auto_'], configPrefixes = ['fl-auto-']) { testLogger.info('Starting fleet artifact cleanup', { fleetPrefixes, configPrefixes }); const headers = { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }; const api = (path) => `${this.baseUrl}/api/${this.org}${path}`; const json = async (res) => { try { return await res.json(); } catch { return null; } }; const starts = (value, prefixes) => typeof value === 'string' && prefixes.some((p) => value.startsWith(p)); const removed = { tokens: 0, fleets: 0, configs: 0 }; // The fl_auto_ roles and users do not depend on the fleet API, so sweep them even when it is unavailable. for (const prefix of fleetPrefixes) await this.cleanupIamArtifacts(prefix); try { const fleetsRes = await this._fetch(api('/fleet/fleets?offset=0&limit=1000'), { method: 'GET', headers }); if (!fleetsRes.ok) { testLogger.info('Fleet cleanup skipped — fleet API unavailable (OSS build or fleet off)', { status: fleetsRes.status }); return removed; } const fleets = ((await json(fleetsRes))?.items ?? []).filter((f) => starts(f?.name, fleetPrefixes)); const ids = new Set(fleets.map((f) => f.id)); const tokensRes = await this._fetch(api('/fleet/tokens?offset=0&limit=1000'), { method: 'GET', headers }); for (const tok of (tokensRes.ok ? (await json(tokensRes))?.items : null) ?? []) { if (ids.has(tok?.fleet?.id) && !tok.revoked) { await this._fetch(api(`/fleet/tokens/${tok.id}/revoke`), { method: 'POST', headers }); removed.tokens += 1; } } // Deleting a fleet also removes its revoked tokens and rollout history, which unpins its configurations. for (const f of fleets) { await this._fetch(api(`/fleet/fleets/${f.id}`), { method: 'DELETE', headers }); removed.fleets += 1; } const configsRes = await this._fetch(api('/fleet/configs?offset=0&limit=1000'), { method: 'GET', headers }); for (const c of (configsRes.ok ? (await json(configsRes))?.items : null) ?? []) { if (starts(c?.id, configPrefixes)) { await this._fetch(api(`/fleet/configs/${c.id}`), { method: 'DELETE', headers }); removed.configs += 1; } } testLogger.info('Fleet artifact cleanup complete', removed); } catch (e) { testLogger.warn('Fleet artifact cleanup hit an error — continuing', { error: e?.message }); } return removed; } /** * Verify stream exists via API * @param {string} streamName - Name of the stream to verify * @returns {Promise} True if stream exists */ async verifyStreamExists(streamName) { try { const response = await this._fetch(`${process.env.INGESTION_URL}/api/${this.org}/streams`, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' } }); const data = await response.json(); if (response.status === 200 && data.list) { const streamExists = data.list.some(s => s.name === streamName); testLogger.info('Stream existence check', { streamName, exists: streamExists }); return streamExists; } testLogger.warn('Failed to check stream existence', { streamName, status: response.status }); return false; } catch (error) { testLogger.error('Error checking stream existence', { streamName, error: error.message }); return false; } } /** * Clean up test screenshots matching given prefixes from the screenshots directory. * @param {string[]} prefixes - File name prefixes to match for deletion * @param {string} screenshotDir - Absolute path to the screenshots directory */ async cleanupScreenshots(prefixes = [], screenshotDir) { const fs = require('fs/promises'); const path = require('path'); try { await fs.access(screenshotDir); } catch { return; } const allFiles = await fs.readdir(screenshotDir); const files = allFiles.filter(f => prefixes.some(prefix => f.startsWith(prefix)) ); for (const file of files) { await fs.unlink(path.join(screenshotDir, file)); } if (files.length > 0) { testLogger.info(`Cleaned up ${files.length} screenshot(s)`, { prefixes }); } } /** * Delete model-pricing records whose names match any of the provided patterns. * Patterns may be RegExp objects or plain strings (treated as prefix match). * Handles 404/disabled gracefully so it's safe on OSS instances without the feature. * * @param {Array} patterns */ async cleanupModelPricingModels(patterns = []) { testLogger.info('Starting model pricing cleanup', { patternCount: patterns.length }); const apiBase = `${this.baseUrl}/api/${this.org}/llm/models`; try { const listRes = await this._fetch(apiBase, { method: 'GET', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, }); if (!listRes.ok) { if (listRes.status === 404) { testLogger.info('Model pricing endpoint not available — skipping cleanup'); } else { testLogger.warn('Failed to list model pricing records', { status: listRes.status }); } return; } const data = await listRes.json(); const models = data.list || data || []; const matches = models.filter(m => { if (!m.name) return false; return patterns.some(p => p instanceof RegExp ? p.test(m.name) : m.name.startsWith(p) ); }); if (matches.length === 0) { testLogger.info('No model pricing records matched cleanup patterns'); return; } testLogger.info(`Deleting ${matches.length} model pricing record(s)`, { names: matches.map(m => m.name), }); await Promise.all(matches.map(m => this._fetch(`${apiBase}/${m.id}`, { method: 'DELETE', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }, }).catch(err => testLogger.warn('Failed to delete model pricing record', { id: m.id, error: err.message })) )); testLogger.info('Model pricing cleanup completed'); } catch (err) { testLogger.warn('Model pricing cleanup failed (non-fatal)', { error: err.message }); } } // A 404 on the checks list means ZO_SYNTHETICS_ENABLED is off, so there is nothing else to sweep. async cleanupSynthetics(prefixes = [], { locationIds = [] } = {}) { if (!prefixes.length) return; testLogger.info('Starting synthetics cleanup', { prefixes: prefixes.map(p => p.source || p) }); const apiBase = `${this.baseUrl}/api/${this.org}/synthetics`; const headers = { 'Authorization': this.authHeader, 'Content-Type': 'application/json' }; const matches = (name) => typeof name === 'string' && prefixes.some(p => (p instanceof RegExp) ? p.test(name) : name.startsWith(p)); try { await this._sweepStatusPages(matches, headers); const listRes = await this._fetch(apiBase, { method: 'GET', headers }); if (listRes.status === 404) { testLogger.info('Synthetics endpoint not available — skipping cleanup'); return; } if (!listRes.ok) { testLogger.warn('Failed to list synthetics checks', { status: listRes.status }); return; } const ids = ((await listRes.json()).checks || []).filter(c => matches(c.name)).map(c => c.id); if (ids.length) { const delRes = await this._fetch(apiBase, { method: 'DELETE', headers, body: JSON.stringify({ ids }) }); testLogger.info('Synthetics checks cleanup completed', { count: ids.length, status: delRes.status }); } else { testLogger.info('No synthetics checks matched cleanup patterns'); } await this._sweepSyntheticsLocations(apiBase, headers, matches, locationIds); await this._sweepSyntheticsFolders(headers, matches); await this._disableSyntheticsTokens(apiBase, headers, matches); } catch (err) { testLogger.warn('Synthetics cleanup failed (non-fatal)', { error: err.message }); } } // Per-worker public locations are ids of `-`; shared ones are only removed when named in locationIds. async _sweepSyntheticsLocations(apiBase, headers, matches, locationIds) { const locRes = await this._fetch(`${apiBase}/locations`, { method: 'GET', headers }); if (!locRes.ok) return; const locations = ((await locRes.json()).locations || []) .filter(l => typeof l.id === 'string' && (locationIds.includes(l.id) || matches(l.id.replace(/^[^-]+-/, '')))); let deleted = 0; for (const l of locations) { const res = await this._fetch(`${apiBase}/locations/${encodeURIComponent(l.id)}`, { method: 'DELETE', headers }); // The server refuses to delete a location that a check still uses. if (res.ok) deleted++; else testLogger.warn('Synthetics location not deleted', { id: l.id, status: res.status }); } if (locations.length) testLogger.info('Synthetics locations cleanup completed', { deleted, matched: locations.length }); } async _sweepSyntheticsFolders(headers, matches) { const folderBase = `${this.baseUrl}/api/v2/${this.org}/folders/synthetics`; const folderRes = await this._fetch(folderBase, { method: 'GET', headers }); if (!folderRes.ok) return; const folders = ((await folderRes.json()).list || []).filter(f => matches(f.name)); for (const f of folders) { await this._fetch(`${folderBase}/${encodeURIComponent(f.folderId)}`, { method: 'DELETE', headers }); } if (folders.length) testLogger.info('Synthetics folders cleanup completed', { count: folders.length }); } // Agent tokens have no delete endpoint, so disabling is the strongest cleanup available. async _disableSyntheticsTokens(apiBase, headers, matches) { const tokRes = await this._fetch(`${apiBase}/agent-tokens`, { method: 'GET', headers }); if (!tokRes.ok) return; const tokens = ((await tokRes.json()).tokens || []).filter(t => matches(t.name) && t.enabled); for (const t of tokens) { await this._fetch(`${apiBase}/agent-tokens/${encodeURIComponent(t.name)}`, { method: 'PATCH', headers, body: JSON.stringify({ enabled: false }), }); } if (tokens.length) testLogger.info('Synthetics tokens disabled', { count: tokens.length }); } async _sweepStatusPages(matches, headers) { const base = `${this.baseUrl}/api/${this.org}/status_pages`; const listRes = await this._fetch(base, { method: 'GET', headers }); if (!listRes.ok) return; const pages = ((await listRes.json()).pages || []).filter(p => matches(p.name)); for (const p of pages) { await this._fetch(`${base}/${encodeURIComponent(p.id)}`, { method: 'DELETE', headers }); } if (pages.length) testLogger.info('Status pages cleanup completed', { count: pages.length }); } } module.exports = APICleanup;