mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
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:
co-authored by
Shrinath-O2
Vamsi Karthik R
parent
abc6c1c797
commit
5fcf871ccd
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
+220
@@ -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');
|
||||
});
|
||||
});
|
||||
+256
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user