test(autoe2e): generated E2E tests for #14796 (alert-destination-usage) (#14850)

Co-authored-by: Shrinath-O2 <214651540+Shrinath-O2@users.noreply.github.com>
Co-authored-by: Vamsi Karthik R <vkarthik@openobserve.ai>
This commit is contained in:
Shrinath Rao
2026-09-29 13:01:00 +00:00
committed by GitHub
co-authored by Shrinath-O2 Vamsi Karthik R
parent abc6c1c797
commit 5fcf871ccd
7 changed files with 839 additions and 2 deletions
+2 -1
View File
@@ -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": [
{
@@ -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": []
},
@@ -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 });
}
}
@@ -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();
@@ -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");
},
);
});
@@ -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 <name> 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');
});
});
@@ -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');
});
});