diff --git a/tests/ui-testing/ci-matrix/ci_matrix.json b/tests/ui-testing/ci-matrix/ci_matrix.json index 69600e4973..6c3d1b62dc 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix.json +++ b/tests/ui-testing/ci-matrix/ci_matrix.json @@ -26,7 +26,8 @@ "alerts-chart-error-state.spec.js", "alertsPagination.spec.js", "alertTemplatesPagination.spec.js", - "alertDestinationsPagination.spec.js" + "alertDestinationsPagination.spec.js", + "alerts-destinations-usage.spec.js" ], "disabled": [ { diff --git a/tests/ui-testing/ci-matrix/ci_matrix_regression.json b/tests/ui-testing/ci-matrix/ci_matrix_regression.json index 60e17679ab..2dcaa76360 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix_regression.json +++ b/tests/ui-testing/ci-matrix/ci_matrix_regression.json @@ -42,7 +42,8 @@ "alerts-measure-column-default.spec.js", "alerts-from-dashboard-panel.spec.js", "alerts-query-editor-search-endpoint.spec.js", - "alerts-13243-prebuilt-form-reuse.spec.js" + "alerts-13243-prebuilt-form-reuse.spec.js", + "alerts-14796-destination-usage.spec.js" ], "disabled": [] }, diff --git a/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js b/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js index 7c465eb61e..aa5da6be9d 100644 --- a/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js +++ b/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js @@ -2759,4 +2759,157 @@ export class AlertDestinationsPage { await popover.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); testLogger.debug('Selected template', { templateName: templateName || 'first available' }); } + + // ============================================================================ + // USED-BY CELL + IMPACT DIALOG (dependency-impact) + // ============================================================================ + + /** The whole "Used by" cell button for a destination row (opens the impact dialog). */ + getUsedByCell(name) { + return this.page.locator(`[data-test="used-by-${name}"]`); + } + + /** Per-kind count chip in the "Used by" cell (e.g. used-by-mydest-alert). */ + getUsedByBadge(name, kind) { + return this.page.locator(`[data-test="used-by-${name}-${kind}"]`); + } + + /** The "Unused" (orphan) chip for a destination row. */ + getUnusedChip(name) { + return this.page.locator(`[data-test="used-by-${name}-unused"]`); + } + + getImpactBody() { + return this.page.locator('[data-test="dependency-impact-body"]'); + } + + getImpactSubtitle() { + return this.page.locator('[data-test="dependency-impact-subtitle"]'); + } + + getAlertLane() { + return this.page.locator('[data-test="dependency-impact-lane-alert"]'); + } + + getAlertRow(name) { + return this.page.locator(`[data-test="dependency-impact-row-${name}"]`); + } + + getDeleteEntityBtn(name) { + return this.page.locator(`[data-test="dependency-impact-delete-${name}"]`); + } + + getNoConsumers() { + return this.page.locator('[data-test="dependency-impact-no-consumers"]'); + } + + /** + * Wait for a per-kind "Used by" badge to paint (cold read first shows the neutral + * graph icon), then assert its count text. + */ + async expectUsedByBadgeCount(name, kind, count) { + const badge = this.getUsedByBadge(name, kind); + await expect(badge).toBeVisible({ timeout: 30000 }); + // Standalone-token match so a badge of "13"/"21" cannot satisfy a count of "1" (badge also holds an icon). + await expect(badge).toContainText(new RegExp(`(?:^|\\D)${count}(?:\\D|$)`), { timeout: 15000 }); + } + + async expectUnusedChipVisible(name) { + await expect(this.getUnusedChip(name)).toBeVisible({ timeout: 30000 }); + } + + async expectUnusedChipAbsent(name) { + await expect(this.getUnusedChip(name)).toHaveCount(0, { timeout: 10000 }); + } + + /** Click the "Used by" cell and await the impact dialog body. */ + async openImpactDialog(name) { + await this.getUsedByCell(name).click(); + await expect(this.getImpactBody()).toBeVisible({ timeout: 30000 }); + } + + async expectImpactSubtitleToContain(text) { + await expect(this.getImpactSubtitle()).toContainText(text, { timeout: 15000 }); + } + + async expectAlertRowVisible(name) { + await expect(this.getAlertRow(name)).toBeVisible({ timeout: 15000 }); + } + + async expectAlertRowAbsent(name) { + await expect(this.getAlertRow(name)).toHaveCount(0, { timeout: 15000 }); + } + + async expectAlertLaneRowCount(count) { + await expect( + this.getAlertLane().locator('[data-test^="dependency-impact-row-"]'), + ).toHaveCount(count, { timeout: 15000 }); + } + + async expectNoConsumersVisible() { + await expect(this.getNoConsumers()).toBeVisible({ timeout: 15000 }); + } + + /** Delete one alert row from inside the impact dialog (hover-reveal + confirm). */ + async deleteEntityFromDialog(name) { + const row = this.getAlertRow(name); + await row.hover(); + const deleteBtn = this.getDeleteEntityBtn(name); + await expect(deleteBtn).toBeVisible({ timeout: 5000 }); + await deleteBtn.click(); + await this.page.locator(this.confirmButton).click(); + } + + async closeImpactDialog() { + await this.page.locator('[data-test="dependency-impact-close"]').click(); + await expect(this.getImpactBody()).toHaveCount(0, { timeout: 15000 }).catch(() => {}); + } + + /** Click a destination's delete button and confirm (blocked-delete 409 or successful delete); asserts neither outcome. */ + async attemptDeleteDestination(name) { + const deleteBtn = this.getDeleteDestinationBtn(name); + await deleteBtn.waitFor({ state: 'visible', timeout: 10000 }); + await deleteBtn.click(); + const confirmBtn = this.page.locator(this.confirmButton); + await confirmBtn.waitFor({ state: 'visible', timeout: 10000 }); + await confirmBtn.click(); + } + + /** The backend's "is used by ..." error toast surfaced when delete is blocked (409). */ + async expectInUseErrorToast() { + const toast = this.page + .locator(this.errorToastMessage) + .filter({ hasText: this.destinationInUseMessage }); + await expect(toast.first()).toBeVisible({ timeout: 15000 }); + } + + /** + * Assert the blocked-delete toast names every fragment in `fragments`. + * + * The guard reports every blocking consumer in ONE message; asserting only + * "is used by" would still pass against the pre-#14796 behaviour of naming the + * first blocker and returning, which is the defect this guards. + */ + async expectInUseErrorToastContaining(fragments) { + const toast = this.page + .locator(this.errorToastMessage) + .filter({ hasText: this.destinationInUseMessage }) + .first(); + await expect(toast).toBeVisible({ timeout: 15000 }); + for (const fragment of fragments) { + await expect(toast).toContainText(fragment, { timeout: 15000 }); + } + } + + /** The in-use destination row must still be present after a blocked delete. */ + async expectDestinationRowStillVisible(name) { + await expect(this.getDeleteDestinationBtn(name)).toBeVisible({ timeout: 10000 }); + } + + /** A success toast whose message contains `text` (e.g. "deleted" for in-dialog delete). */ + async expectSuccessToastMessageContaining(text) { + await expect( + this.page.locator(this.successToastMessage).first(), + ).toContainText(text, { timeout: 15000 }); + } } \ No newline at end of file diff --git a/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js b/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js index 467117606a..ee32b3b456 100644 --- a/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js +++ b/tests/ui-testing/pages/pipelinesPages/pipelinesPage.js @@ -1176,6 +1176,22 @@ export class PipelinesPage { await this.page.locator(this.enrichmentTableTab).click(); } + /** + * Open Settings -> Pipeline Destinations and wait for the named row. + * + * Pipeline destinations live on their own tab — `destinationsQuery(org, "pipeline")` — + * so the alert destinations page never lists them and cannot reach this row. That + * list also has no search box, so the row is awaited directly rather than filtered. + */ + async openPipelineDestinationsAt(name) { + await this.settingsMenu.click(); + await this.pipelineDestinationsTab.click(); + await expect(this.destinationListAddBtn).toBeVisible({ timeout: 30000 }); + await this.page + .locator(`[data-test="alert-destination-list-${name}-delete-destination"]`) + .waitFor({ state: 'visible', timeout: 30000 }); + } + async deleteDestination(randomNodeName) { await this.settingsMenu.click(); await this.pipelineDestinationsTab.click(); diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-destinations-usage.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-destinations-usage.spec.js new file mode 100644 index 0000000000..96b8000248 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Alerts/alerts-destinations-usage.spec.js @@ -0,0 +1,190 @@ +const { + test, + expect, + navigateToBase, +} = require("../utils/enhanced-baseFixtures.js"); +const testLogger = require("../utils/test-logger.js"); +const PageManager = require("../../pages/page-manager.js"); + +/** + * Alert Destination Usage (Dependency Impact) E2E Tests + * + * Covers the read-only "Used by" reporting surface plus the delete-guard (409) and + * in-dialog delete paths of the dependency-impact feature: + * - a used destination renders a per-kind alert count badge in the "Used by" cell; + * - clicking the cell opens the impact dialog listing the same downstream alerts; + * - an orphan destination shows the "Unused" chip and a "No consumers." dialog; + * - deleting an in-use destination surfaces the 409 "is used by" error toast and + * leaves the row in place; + * - deleting an alert from inside the dialog decrements the parent count in place. + * + * Data fixtures use the existing pm.apiCleanup helpers (see the setup contract); + * every test seeds its own uniquely-named resources — a folder/destination/template/ + * stream for in-use destinations, or just a template + destination for the orphan + * case — and tears them down in afterEach, so the suite stays fully parallel-safe. + */ + +// Unique per-test seed prefix. Tests run in parallel against a shared org, so +// every test seeds its own resources and tears them down in afterEach. +const uniqueSeed = () => + `e2e_dest_usage_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +test.describe("Alert Destination Usage (Dependency Impact) testcases", () => { + test.describe.configure({ mode: "parallel" }); + let pm; + // Per-test seeded resources, reset in beforeEach so parallel workers never collide. + let folderId = null; + let streamName = null; + let templateName = null; + let destinationName = null; + let standaloneTemplateName = null; + let standaloneDestinationName = null; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + folderId = null; + streamName = null; + templateName = null; + destinationName = null; + standaloneTemplateName = null; + standaloneDestinationName = null; + testLogger.info("Test setup completed"); + }); + + test.afterEach(async () => { + // Cleanup ordering matters: alerts first (folder delete is rejected while it + // still contains alerts), then destination/template, then folder, then stream. + if (folderId) { + const alerts = await pm.apiCleanup.fetchAlertsInFolder(folderId); + for (const alert of alerts) { + await pm.apiCleanup.deleteAlert(alert.alert_id, folderId); + } + } + if (destinationName) await pm.apiCleanup.deleteAlertDestination(destinationName); + if (templateName) await pm.apiCleanup.deleteAlertTemplate(templateName); + if (folderId) await pm.apiCleanup.deleteFolder(folderId); + if (streamName) await pm.apiCleanup.deleteStream(streamName); + if (standaloneDestinationName) await pm.apiCleanup.deleteAlertDestination(standaloneDestinationName); + if (standaloneTemplateName) await pm.apiCleanup.deleteAlertTemplate(standaloneTemplateName); + }); + + // Seed a destination used by `count` alerts (folder + stream + template + + // destination + alerts), recording the resources for afterEach cleanup. + async function seedUsedDestination(count) { + const seed = uniqueSeed(); + const folder = await pm.apiCleanup.createAlertFolder(`${seed}_folder`); + folderId = folder.folderId; + const seeded = await pm.apiCleanup.seedAlertsInFolder(folderId, count, seed); + destinationName = seeded.destinationName; + templateName = seeded.templateName; + streamName = seeded.streamName; + return seeded; + } + + // Navigate to the destinations list cold and scope it to one destination so the + // "Used by" cell is unambiguously on screen (the list is paginated at 20 rows). + async function navigateToListAndScope(name) { + await pm.alertDestinationsPage.navigateToDestinations(); + await pm.alertDestinationsPage.waitForDestinationListReady(); + await pm.alertDestinationsPage.searchDestinations(name); + } + + test( + "should render the alert count badge in the Used by cell for a used destination", + { tag: ["@alert-destination-usage", "@all"] }, + async ({ page }) => { + testLogger.info("Seeding a destination used by 3 alerts"); + await seedUsedDestination(3); + + await navigateToListAndScope(destinationName); + + testLogger.info("Asserting the alert badge shows count 3 and no Unused chip"); + await pm.alertDestinationsPage.expectUsedByBadgeCount(destinationName, "alert", 3); + await pm.alertDestinationsPage.expectUnusedChipAbsent(destinationName); + testLogger.info("Test completed"); + }, + ); + + test( + "should open the impact dialog and list the destination's downstream alerts without disagreement", + { tag: ["@alert-destination-usage", "@all"] }, + async ({ page }) => { + testLogger.info("Seeding a destination used by 3 alerts"); + const seeded = await seedUsedDestination(3); + + await navigateToListAndScope(destinationName); + + testLogger.info("Opening the impact dialog and asserting cell/dialog agreement"); + await pm.alertDestinationsPage.openImpactDialog(destinationName); + await pm.alertDestinationsPage.expectImpactSubtitleToContain("Used by 3"); + await pm.alertDestinationsPage.expectAlertLaneRowCount(3); + for (const alert of seeded.alerts) { + await pm.alertDestinationsPage.expectAlertRowVisible(alert.name); + } + await pm.alertDestinationsPage.closeImpactDialog(); + testLogger.info("Test completed"); + }, + ); + + test( + "should show the Unused chip and a No consumers dialog for an orphan destination", + { tag: ["@alert-destination-usage", "@all"] }, + async ({ page }) => { + testLogger.info("Seeding an orphan destination (no referencing alerts)"); + const seed = uniqueSeed(); + standaloneTemplateName = `${seed}_tmpl`; + standaloneDestinationName = `${seed}_dest`; + await pm.apiCleanup.createAlertTemplate(standaloneTemplateName); + await pm.apiCleanup.createAlertDestination(standaloneDestinationName, standaloneTemplateName); + + await navigateToListAndScope(standaloneDestinationName); + + testLogger.info("Asserting the Unused chip and no-consumers dialog"); + await pm.alertDestinationsPage.expectUnusedChipVisible(standaloneDestinationName); + await pm.alertDestinationsPage.openImpactDialog(standaloneDestinationName); + await pm.alertDestinationsPage.expectNoConsumersVisible(); + await pm.alertDestinationsPage.closeImpactDialog(); + testLogger.info("Test completed"); + }, + ); + + test( + "should surface the 409 in-use error when deleting a destination referenced by alerts", + { tag: ["@alert-destination-usage", "@all"] }, + async ({ page }) => { + testLogger.info("Seeding a destination used by 2 alerts"); + await seedUsedDestination(2); + + await navigateToListAndScope(destinationName); + + testLogger.info("Attempting delete and asserting the in-use error toast + retained row"); + await pm.alertDestinationsPage.attemptDeleteDestination(destinationName); + await pm.alertDestinationsPage.expectInUseErrorToast(); + await pm.alertDestinationsPage.expectDestinationRowStillVisible(destinationName); + testLogger.info("Test completed"); + }, + ); + + test( + "should decrement the Used by count when an alert is deleted from the impact dialog", + { tag: ["@alert-destination-usage", "@all"] }, + async ({ page }) => { + testLogger.info("Seeding a destination used by 2 alerts"); + const seeded = await seedUsedDestination(2); + const victimName = seeded.alerts[0].name; + + await navigateToListAndScope(destinationName); + + testLogger.info("Deleting one alert from the dialog and asserting the count decrements"); + await pm.alertDestinationsPage.openImpactDialog(destinationName); + await pm.alertDestinationsPage.deleteEntityFromDialog(victimName); + await pm.alertDestinationsPage.expectAlertRowAbsent(victimName); + await pm.alertDestinationsPage.expectSuccessToastMessageContaining("deleted"); + await pm.alertDestinationsPage.closeImpactDialog(); + await pm.alertDestinationsPage.expectUsedByBadgeCount(destinationName, "alert", 1); + testLogger.info("Test completed"); + }, + ); +}); diff --git a/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-14796-destination-usage-ent.spec.js b/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-14796-destination-usage-ent.spec.js new file mode 100644 index 0000000000..d73e2f4ff1 --- /dev/null +++ b/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-14796-destination-usage-ent.spec.js @@ -0,0 +1,220 @@ +// Copyright 2026 OpenObserve Inc. + +/** + * Alerts Regression — enterprise consumers of the destination delete guard (#14796) + * + * ENTERPRISE ONLY. This file is deliberately absent from OSS `ci_matrix_regression.json` + * and is switched on by `append` in the enterprise `ci_matrix_regression.ent.json`, + * the same way `incidents-status-filter.spec.js` is. Three of the guard's nine + * consumers are `#[cfg(feature = "enterprise")]` and simply do not exist in an OSS + * build, so an OSS run would fail on fixture creation rather than skip. + * + * `alerts-14796-destination-usage.spec.js` covers the OSS consumers. This covers the + * enterprise ones, each of which was an unguarded store before #14796: + * - anomaly detection (`anomaly_detection_config.alert_destinations`) + * - incident integrations (`incident_integrations.destinations`) + * - workflows (a `destination` node's `destination_id`) + * + * Two surfaces, not one. Anomaly and incident integrations reference ALERT-module + * destinations, so they appear on the alerts destinations list and carry a per-kind + * "Used by" badge. A workflow references a PIPELINE-module destination — the alerts + * list never shows it (`destinationsQuery(org, "alert")`), so only the refusal is + * assertable there, from the Settings -> Pipeline Destinations tab. + * + * Requires `O2_WORKFLOWS_ENABLED` and `O2_INCIDENTS_ENABLED` on the server under test. + */ + +const { test, expect, navigateToBase } = require('../../utils/enhanced-baseFixtures.js'); +const PageManager = require('../../../pages/page-manager.js'); +const testLogger = require('../../utils/test-logger.js'); +const { uniq, urls, api, seedAlertFixtures } = require('../../utils/alerts-api-helpers.js'); + +test.describe('Enterprise consumers of the destination delete guard testcases', { + tag: ['@alerts', '@alert-destination-usage', '@enterprise'], +}, () => { + test.describe.configure({ mode: 'parallel' }); + + let pm; + let createdTemplates = []; + let createdDestinations = []; + // Each entry is a { url } the afterEach DELETEs before the destinations it holds. + let createdConsumers = []; + // Streams ingested as anomaly-detection fixtures; deleted in afterEach. + let createdStreams = []; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + pm = new PageManager(page); + createdTemplates = []; + createdDestinations = []; + createdConsumers = []; + createdStreams = []; + await seedAlertFixtures(page); + await navigateToBase(page); + }); + + test.afterEach(async ({ page }) => { + const { v1 } = urls(); + // Consumers first: while one still names a destination, the guard under test + // refuses that destination's delete and the fixture would leak. + for (const url of [...createdConsumers].reverse()) { + await api(page, 'delete', url).catch(() => {}); + } + for (const name of createdDestinations) { + await api(page, 'delete', `${v1}/alerts/destinations/${name}`).catch(() => {}); + } + for (const name of createdTemplates) { + await api(page, 'delete', `${v1}/alerts/templates/${name}`).catch(() => {}); + } + for (const name of createdStreams) { + await pm.apiCleanup.deleteStream(name, 'logs').catch(() => {}); + } + }); + + /** + * A destination owned by this test alone. `module` picks the surface: "alert" + * destinations carry a template and show on the alerts list; "pipeline" ones carry + * none and are what a workflow node can point at. + */ + async function seedDestination(page, prefix, module = 'alert') { + const { v1 } = urls(); + const destinationName = uniq(`${prefix}_dest`); + const body = { + name: destinationName, url: 'http://127.0.0.1:1/never-called', + method: 'post', type: 'http', + }; + if (module === 'alert') { + const templateName = uniq(`${prefix}_tmpl`); + const tplRes = await api(page, 'post', `${v1}/alerts/templates`, { + name: templateName, body: '{"text":"{alert_name}"}', type: 'http', title: '', + }); + expect(tplRes.status(), await tplRes.text()).toBe(200); + createdTemplates.push(templateName); + body.template = templateName; + } + const query = module === 'pipeline' ? '?module=pipeline' : ''; + const res = await api(page, 'post', `${v1}/alerts/destinations${query}`, body); + expect(res.status(), await res.text()).toBe(200); + createdDestinations.push(destinationName); + return destinationName; + } + + async function openDestinationsScopedTo(name) { + await pm.alertDestinationsPage.navigateToDestinations(); + await pm.alertDestinationsPage.waitForDestinationListReady(); + await pm.alertDestinationsPage.searchDestinations(name); + } + + test('should count an anomaly detection config and refuse its destination delete', { + tag: ['@alert-destination-anomaly-consumer', '@enterprise', '@alerts', '@P0'], + }, async ({ page }) => { + const { v1 } = urls(); + testLogger.info('Seeding a destination referenced only by an anomaly detection config'); + const destinationName = await seedDestination(page, 'e2e_14796_anom'); + const stream = uniq('e2e_14796_anom_stream'); + createdStreams.push(stream); + await api(page, 'post', `${v1}/${stream}/_json`, [{ level: 'error', latency: 10 }]); + + const configName = uniq('e2e_14796_anom_cfg'); + const res = await api(page, 'post', `${v1}/anomaly_detection`, { + name: configName, description: '', stream_name: stream, stream_type: 'logs', + query_mode: 'filters', filters: [], detection_function: 'count', + histogram_interval: '1h', schedule_interval: '1h', + // Server floor: schedule_interval + histogram_interval. 1h + 1h = 7200s. + detection_window_seconds: 7200, + alert_enabled: true, alert_destinations: [destinationName], enabled: true, + }); + expect(res.status(), await res.text()).toBe(200); + createdConsumers.push(`${v1}/anomaly_detection/${(await res.json()).anomaly_id}`); + + await openDestinationsScopedTo(destinationName); + + testLogger.info('Asserting the anomaly_detection badge and the refusal'); + await pm.alertDestinationsPage.expectUsedByBadgeCount(destinationName, 'anomaly_detection', 1); + await pm.alertDestinationsPage.expectUnusedChipAbsent(destinationName); + await pm.alertDestinationsPage.attemptDeleteDestination(destinationName); + await pm.alertDestinationsPage.expectInUseErrorToastContaining([ + 'anomaly detection config', configName, + ]); + await pm.alertDestinationsPage.expectDestinationRowStillVisible(destinationName); + testLogger.info('Test completed'); + }); + + test('should count an incident integration and refuse its destination delete', { + tag: ['@alert-destination-incident-consumer', '@enterprise', '@alerts', '@P0'], + }, async ({ page }) => { + const { v2 } = urls(); + testLogger.info('Seeding a destination referenced only by an incident integration'); + const destinationName = await seedDestination(page, 'e2e_14796_inc'); + + const integrationName = uniq('e2e_14796_inc_src'); + const res = await api(page, 'post', `${v2}/incidents/integrations`, { + // Closed set: auto | grafana | alertmanager | generic. + name: integrationName, source_type: 'generic', enabled: true, + config: {}, destinations: [destinationName], + }); + expect(res.status(), await res.text()).toBe(200); + createdConsumers.push(`${v2}/incidents/integrations/${(await res.json()).id}`); + + await openDestinationsScopedTo(destinationName); + + testLogger.info('Asserting the incident_integration badge and the refusal'); + await pm.alertDestinationsPage.expectUsedByBadgeCount( + destinationName, 'incident_integration', 1, + ); + await pm.alertDestinationsPage.expectUnusedChipAbsent(destinationName); + await pm.alertDestinationsPage.attemptDeleteDestination(destinationName); + await pm.alertDestinationsPage.expectInUseErrorToastContaining([ + 'incident integration', integrationName, + ]); + await pm.alertDestinationsPage.expectDestinationRowStillVisible(destinationName); + testLogger.info('Test completed'); + }); + + test('should refuse deleting a destination a workflow still delivers to', { + tag: ['@alert-destination-workflow-consumer', '@enterprise', '@alerts', '@P0'], + }, async ({ page }) => { + const { v1, org } = urls(); + testLogger.info('Seeding a pipeline-module destination and a workflow that delivers to it'); + // A workflow destination node rejects an alert-module destination outright: + // "destination is not a workflow compatible destination". + const destinationName = await seedDestination(page, 'e2e_14796_wf', 'pipeline'); + + const workflowName = uniq('e2e_14796_wf'); + const res = await api(page, 'post', `${v1}/workflows`, { + trigger_type: 'alert', + workflow: { + id: '', org_id: org, folder_id: '', created_at: 0, updated_at: 0, + created_by: 'root@example.com', enabled: true, + name: workflowName, description: '', + nodes: [ + { + id: 'n1', type: 'input', io_type: 'input', position: { x: 0, y: 0 }, + data: { node_type: 'workflow_trigger' }, + }, + { + id: 'n2', type: 'output', io_type: 'output', position: { x: 200, y: 100 }, + data: { + node_type: 'destination', + destination_id: destinationName, + template_override: null, + }, + }, + ], + edges: [{ id: 'e1', source: 'n1', target: 'n2' }], + }, + }); + expect(res.status(), await res.text()).toBe(200); + createdConsumers.push(`${v1}/workflows/${(await res.json()).id}`); + + // Pipeline-module destinations are not on the alerts list, so there is no + // "Used by" cell to assert here — only the refusal. + await pm.pipelinesPage.openPipelineDestinationsAt(destinationName); + + testLogger.info('Asserting the delete is refused and names the workflow'); + await pm.alertDestinationsPage.attemptDeleteDestination(destinationName); + await pm.alertDestinationsPage.expectInUseErrorToastContaining(['workflow', workflowName]); + await pm.alertDestinationsPage.expectDestinationRowStillVisible(destinationName); + testLogger.info('Test completed'); + }); +}); diff --git a/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-14796-destination-usage.spec.js b/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-14796-destination-usage.spec.js new file mode 100644 index 0000000000..f08aa3dca9 --- /dev/null +++ b/tests/ui-testing/playwright-tests/RegressionSet/Alerts/alerts-14796-destination-usage.spec.js @@ -0,0 +1,256 @@ +// Copyright 2026 OpenObserve Inc. + +/** + * Alerts Regression — destination delete guard across consumers (#14796) + * + * Before #14796 the delete guard consulted three consumers (the ALERTS cache, + * pipelines, enterprise workflows). Six other stores reference a destination by + * NAME and were never asked, so a destination a composite alert or a synthetic + * check depended on deleted cleanly and its owner stopped being paged. The list + * made that easy to do: "Used by" counted alerts only, so such a destination + * read Unused, and the screen that says "safe to delete" is what causes it. + * + * `Alerts/alerts-destinations-usage.spec.js` covers the alert consumer. This file + * covers the parts of the fix that spec does not: + * - a COMPOSITE-alert-only destination is counted and blocks its own delete + * (composites live in `alert_composites`, outside the `alerts` table, which is + * exactly why the old cache-walking guard could not see them); + * - a destination held by two different KINDS is refused once, naming both — + * the old guard returned on its first match, so a destination used by several + * things was removed one complaint at a time; + * - a genuinely unused destination still deletes, so proving nine tables clean + * never turns into over-blocking. + * + * Not covered here, deliberately: the synthetic-check consumer, the arm that + * produced the original report. Synthetics is off in CI (neither workflow sets + * `ZO_SYNTHETICS_ENABLED`) and its locations have no env-based seeding, so a check + * cannot be created. Enabling it is a workflow change, not a spec change. + */ + +const { test, expect, navigateToBase } = require('../../utils/enhanced-baseFixtures.js'); +const PageManager = require('../../../pages/page-manager.js'); +const testLogger = require('../../utils/test-logger.js'); +const { + uniq, urls, api, DEST, simpleAlert, compositeAlert, + createAlert, findAlertId, deleteAlertInFolder, seedAlertFixtures, +} = require('../../utils/alerts-api-helpers.js'); + +test.describe('Alert destination delete guard across consumers testcases', { + tag: ['@alerts', '@alert-destination-usage'], +}, () => { + test.describe.configure({ mode: 'parallel' }); + + let pm; + // Alert ids in creation order; torn down newest-first so a composite goes before + // the child it names. + let createdAlerts = []; + let createdDestinations = []; + let createdTemplates = []; + let createdPipelines = []; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + pm = new PageManager(page); + createdAlerts = []; + createdDestinations = []; + createdTemplates = []; + createdPipelines = []; + await seedAlertFixtures(page); + await navigateToBase(page); + }); + + test.afterEach(async ({ page }) => { + const { v1 } = urls(); + // Consumers first — while one still names a destination, the guard under test + // refuses that destination's delete and the fixture would leak. + for (const id of [...createdAlerts].reverse()) { + await deleteAlertInFolder(page, id, 'default'); + } + for (const id of createdPipelines) { + await api(page, 'delete', `${v1}/pipelines/${id}`).catch(() => {}); + } + for (const name of createdDestinations) { + await api(page, 'delete', `${v1}/alerts/destinations/${name}`).catch(() => {}); + } + for (const name of createdTemplates) { + await api(page, 'delete', `${v1}/alerts/templates/${name}`).catch(() => {}); + } + }); + + /** A template + destination owned by this test alone, so its counts are unambiguous. */ + async function seedDestination(page, prefix) { + const { v1 } = urls(); + const templateName = uniq(`${prefix}_tmpl`); + const destinationName = uniq(`${prefix}_dest`); + const tplRes = await api(page, 'post', `${v1}/alerts/templates`, { + name: templateName, body: '{"text":"{alert_name}"}', type: 'http', title: '', + }); + expect(tplRes.status(), await tplRes.text()).toBe(200); + createdTemplates.push(templateName); + // Unroutable on purpose: these alerts are never meant to fire, and a real + // host would turn a notification attempt into flaky wall-clock. + const destRes = await api(page, 'post', `${v1}/alerts/destinations`, { + name: destinationName, url: 'http://127.0.0.1:1/never-called', + method: 'post', template: templateName, type: 'http', + }); + expect(destRes.status(), await destRes.text()).toBe(200); + createdDestinations.push(destinationName); + return destinationName; + } + + async function createSimpleAlertOn(page, destinationName, prefix) { + const name = uniq(prefix); + const res = await createAlert(page, { ...simpleAlert(name), destinations: [destinationName] }); + expect(res.status(), await res.text()).toBe(200); + const id = await findAlertId(page, name); + expect(id, 'seeded alert must exist').toBeTruthy(); + createdAlerts.push(id); + return { id, name }; + } + + // `childIds` is a list because the server rejects a composite with fewer than + // two children ("composite needs at least 2 children"). + async function createCompositeAlertOn(page, destinationName, childIds, prefix) { + const name = uniq(prefix); + const res = await createAlert( + page, + compositeAlert(name, childIds, { destinations: [destinationName] }), + ); + expect(res.status(), await res.text()).toBe(200); + const id = await findAlertId(page, name); + expect(id, 'seeded composite must exist').toBeTruthy(); + createdAlerts.push(id); + return { id, name }; + } + + /** + * A pipeline-module destination (`?module=pipeline`) plus a realtime pipeline whose + * `remote_stream` node names it. Pipeline destinations carry no template and never + * appear on the alert destinations list, so this is the only way to reach that arm. + */ + async function seedPipelineOnDestination(page, prefix) { + const { v1, org } = urls(); + const destinationName = uniq(`${prefix}_dest`); + const destRes = await api(page, 'post', `${v1}/alerts/destinations?module=pipeline`, { + name: destinationName, url: 'http://127.0.0.1:1/never-called', method: 'post', type: 'http', + }); + expect(destRes.status(), await destRes.text()).toBe(200); + createdDestinations.push(destinationName); + + const pipelineName = uniq(`${prefix}_pipeline`); + const stream = 'alerts_p0_stream'; + const pipeRes = await api(page, 'post', `${v1}/pipelines`, { + name: pipelineName, description: '', org, enabled: true, + source: { source_type: 'realtime', org_id: org, stream_name: stream, stream_type: 'logs' }, + nodes: [ + { + id: 'n1', type: 'input', io_type: 'input', position: { x: 0, y: 0 }, + data: { node_type: 'stream', org_id: org, stream_name: stream, stream_type: 'logs' }, + }, + { + id: 'n2', type: 'output', io_type: 'output', position: { x: 200, y: 200 }, + data: { node_type: 'remote_stream', org_id: org, destination_name: destinationName }, + }, + ], + edges: [{ id: 'e1', source: 'n1', target: 'n2' }], + }); + expect(pipeRes.status(), await pipeRes.text()).toBe(200); + createdPipelines.push((await pipeRes.json()).id); + return { destinationName, pipelineName }; + } + + async function openDestinationsScopedTo(name) { + await pm.alertDestinationsPage.navigateToDestinations(); + await pm.alertDestinationsPage.waitForDestinationListReady(); + await pm.alertDestinationsPage.searchDestinations(name); + } + + test('should count a composite-alert-only destination and refuse its delete', { + tag: ['@alert-destination-composite-consumer', '@all', '@alerts', '@P0'], + }, async ({ page }) => { + testLogger.info('Seeding a destination referenced only by a composite alert'); + const destinationName = await seedDestination(page, 'e2e_14796_comp'); + // Both children keep the shared fixture destination (DEST), so the seeded + // destination is reachable through the composite and through nothing else. + const childA = await createSimpleAlertOn(page, DEST, 'e2e_14796_child_a'); + const childB = await createSimpleAlertOn(page, DEST, 'e2e_14796_child_b'); + const composite = await createCompositeAlertOn( + page, destinationName, [childA.id, childB.id], 'e2e_14796_composite', + ); + + await openDestinationsScopedTo(destinationName); + + testLogger.info('Asserting the composite_alert badge and the absence of the Unused chip'); + await pm.alertDestinationsPage.expectUsedByBadgeCount(destinationName, 'composite_alert', 1); + await pm.alertDestinationsPage.expectUnusedChipAbsent(destinationName); + + testLogger.info('Asserting the delete is refused and names the composite'); + await pm.alertDestinationsPage.attemptDeleteDestination(destinationName); + await pm.alertDestinationsPage.expectInUseErrorToastContaining([ + 'composite alert', composite.name, + ]); + await pm.alertDestinationsPage.expectDestinationRowStillVisible(destinationName); + testLogger.info('Test completed'); + }); + + test('should name every blocking kind in one refusal, not just the first', { + tag: ['@alert-destination-multi-consumer', '@all', '@alerts', '@P0'], + }, async ({ page }) => { + testLogger.info('Seeding one destination held by both a simple and a composite alert'); + const destinationName = await seedDestination(page, 'e2e_14796_multi'); + const plain = await createSimpleAlertOn(page, destinationName, 'e2e_14796_multi_alert'); + // The second child stays on the shared fixture destination so the seeded one + // is held by exactly one alert and one composite — the counts under test. + const filler = await createSimpleAlertOn(page, DEST, 'e2e_14796_multi_filler'); + const composite = await createCompositeAlertOn( + page, destinationName, [plain.id, filler.id], 'e2e_14796_multi_composite', + ); + + await openDestinationsScopedTo(destinationName); + + testLogger.info('Both kinds must carry their own badge'); + await pm.alertDestinationsPage.expectUsedByBadgeCount(destinationName, 'alert', 1); + await pm.alertDestinationsPage.expectUsedByBadgeCount(destinationName, 'composite_alert', 1); + + testLogger.info('One refusal must name both kinds and both alerts'); + await pm.alertDestinationsPage.attemptDeleteDestination(destinationName); + await pm.alertDestinationsPage.expectInUseErrorToastContaining([ + plain.name, 'composite alert', composite.name, + ]); + await pm.alertDestinationsPage.expectDestinationRowStillVisible(destinationName); + testLogger.info('Test completed'); + }); + + test('should refuse deleting a pipeline destination a pipeline still routes to', { + tag: ['@alert-destination-pipeline-consumer', '@all', '@alerts', '@P0'], + }, async ({ page }) => { + testLogger.info('Seeding a pipeline destination and a pipeline that routes to it'); + const { destinationName, pipelineName } = await seedPipelineOnDestination(page, 'e2e_14796_pipe'); + + // Pipeline destinations live on their own Settings tab, not the alerts list. + await pm.pipelinesPage.openPipelineDestinationsAt(destinationName); + + testLogger.info('Asserting the delete is refused and names the pipeline'); + await pm.alertDestinationsPage.attemptDeleteDestination(destinationName); + await pm.alertDestinationsPage.expectInUseErrorToastContaining(['pipeline', pipelineName]); + await pm.alertDestinationsPage.expectDestinationRowStillVisible(destinationName); + testLogger.info('Test completed'); + }); + + test('should still delete a destination no consumer references', { + tag: ['@alert-destination-unused-delete', '@all', '@alerts', '@P0'], + }, async ({ page }) => { + testLogger.info('Seeding a destination nothing references'); + const destinationName = await seedDestination(page, 'e2e_14796_free'); + + await openDestinationsScopedTo(destinationName); + + testLogger.info('Asserting the Unused chip, then a delete that actually succeeds'); + await pm.alertDestinationsPage.expectUnusedChipVisible(destinationName); + await pm.alertDestinationsPage.attemptDeleteDestination(destinationName); + await pm.alertDestinationsPage.expectDestinationNotInList(destinationName); + // Already gone — afterEach must not delete it again and log a spurious 404. + createdDestinations = createdDestinations.filter((n) => n !== destinationName); + testLogger.info('Test completed'); + }); +});