feat: Improve validation of external alerts API input (#1833)

Co-authored-by: Tom Alexander <teeohhem@gmail.com>
This commit is contained in:
Drew Davis 2026-03-03 08:35:51 -05:00 committed by GitHub
parent 181d8d5409
commit 260c429908
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 462 additions and 117 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/api": patch
---
feat: Improve validation of external alert API input

View file

@ -14,8 +14,10 @@ import Alert, {
import Dashboard, { IDashboard } from '@/models/dashboard';
import { ISavedSearch, SavedSearch } from '@/models/savedSearch';
import { IUser } from '@/models/user';
import Webhook from '@/models/webhook';
import { Api400Error } from '@/utils/errors';
import logger from '@/utils/logger';
import { alertSchema } from '@/utils/zod';
import { alertSchema, objectIdSchema } from '@/utils/zod';
export type AlertInput = {
id?: string;
@ -45,6 +47,63 @@ export type AlertInput = {
};
};
const validateObjectId = (id: string | undefined, message: string) => {
if (objectIdSchema.safeParse(id).success === false) {
throw new Api400Error(message);
}
};
export const validateAlertInput = async (
teamId: ObjectId,
alertInput: Pick<
AlertInput,
'source' | 'dashboardId' | 'tileId' | 'savedSearchId' | 'channel'
>,
) => {
if (alertInput.source === AlertSource.TILE) {
validateObjectId(alertInput.dashboardId, 'Invalid dashboard ID');
const dashboard = await Dashboard.findOne({
_id: alertInput.dashboardId,
team: teamId,
});
if (dashboard == null) {
throw new Api400Error('Dashboard not found');
}
if (dashboard.tiles.find(tile => tile.id === alertInput.tileId) == null) {
throw new Api400Error('Tile not found');
}
}
if (alertInput.source === AlertSource.SAVED_SEARCH) {
validateObjectId(alertInput.savedSearchId, 'Invalid saved search ID');
const savedSearch = await SavedSearch.findOne({
_id: alertInput.savedSearchId,
team: teamId,
});
if (savedSearch == null) {
throw new Api400Error('Saved search not found');
}
}
if (alertInput.channel.type === 'webhook') {
validateObjectId(alertInput.channel.webhookId, 'Invalid webhook ID');
if (
(await Webhook.findOne({
_id: alertInput.channel.webhookId,
team: teamId,
})) == null
) {
throw new Api400Error('Webhook not found');
}
}
};
const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
return {
channel: alert.channel,
@ -75,18 +134,6 @@ export const createAlert = async (
alertInput: z.infer<typeof alertSchema>,
userId: ObjectId,
) => {
if (alertInput.source === AlertSource.TILE) {
if ((await Dashboard.findById(alertInput.dashboardId)) == null) {
throw new Error('Dashboard ID not found');
}
}
if (alertInput.source === AlertSource.SAVED_SEARCH) {
if ((await SavedSearch.findById(alertInput.savedSearchId)) == null) {
throw new Error('Saved Search ID not found');
}
}
return new Alert({
...makeAlert(alertInput, userId),
team: teamId,

View file

@ -637,15 +637,17 @@ export const makeAlertInput = ({
interval = '15m',
threshold = 8,
tileId,
webhookId = 'test-webhook-id',
}: {
dashboardId: string;
interval?: AlertInterval;
threshold?: number;
tileId: string;
webhookId?: string;
}): Partial<AlertInput> => ({
channel: {
type: 'webhook',
webhookId: 'test-webhook-id',
webhookId,
},
interval,
threshold,
@ -659,14 +661,16 @@ export const makeSavedSearchAlertInput = ({
savedSearchId,
interval = '15m',
threshold = 8,
webhookId = 'test-webhook-id',
}: {
savedSearchId: string;
interval?: AlertInterval;
threshold?: number;
webhookId?: string;
}): Partial<AlertInput> => ({
channel: {
type: 'webhook',
webhookId: 'test-webhook-id',
webhookId,
},
interval,
threshold,

View file

@ -6,6 +6,7 @@ import {
randomMongoId,
} from '@/fixtures';
import Alert from '@/models/alert';
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
const MOCK_TILES = [makeTile(), makeTile(), makeTile(), makeTile(), makeTile()];
@ -18,11 +19,28 @@ const MOCK_DASHBOARD = {
describe('alerts router', () => {
const server = getServer();
let agent: Awaited<ReturnType<typeof getLoggedInAgent>>['agent'];
let team: Awaited<ReturnType<typeof getLoggedInAgent>>['team'];
let user: Awaited<ReturnType<typeof getLoggedInAgent>>['user'];
let webhook: WebhookDocument;
beforeAll(async () => {
await server.start();
});
beforeEach(async () => {
const result = await getLoggedInAgent(server);
agent = result.agent;
team = result.team;
user = result.user;
webhook = await Webhook.create({
name: 'Test Webhook',
service: WebhookService.Slack,
url: 'https://hooks.slack.com/test',
team: team._id,
});
});
afterEach(async () => {
await server.clearDBs();
});
@ -32,7 +50,6 @@ describe('alerts router', () => {
});
it('can create an alert', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -43,6 +60,7 @@ describe('alerts router', () => {
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
@ -51,7 +69,6 @@ describe('alerts router', () => {
});
it('can delete an alert', async () => {
const { agent } = await getLoggedInAgent(server);
const resp = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -62,6 +79,7 @@ describe('alerts router', () => {
makeAlertInput({
dashboardId: resp.body.id,
tileId: MOCK_TILES[0].id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
@ -71,7 +89,6 @@ describe('alerts router', () => {
});
it('can update an alert', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -82,6 +99,7 @@ describe('alerts router', () => {
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: MOCK_TILES[0].id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
@ -99,7 +117,6 @@ describe('alerts router', () => {
});
it('preserves createdBy field during updates', async () => {
const { agent, user } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -113,6 +130,7 @@ describe('alerts router', () => {
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
threshold: 5,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
@ -146,21 +164,20 @@ describe('alerts router', () => {
});
it('has alerts attached to dashboards', async () => {
const { agent } = await getLoggedInAgent(server);
await agent.post('/dashboards').send(MOCK_DASHBOARD).expect(200);
const initialDashboards = await agent.get('/dashboards').expect(200);
// Create alerts for all charts
const dashboard = initialDashboards.body[0];
await Promise.all(
dashboard.tiles.map(tile =>
dashboard.tiles.map((tile: { id: string }) =>
agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard._id,
tileId: tile.id,
webhookId: webhook._id.toString(),
}),
)
.expect(200),
@ -176,7 +193,6 @@ describe('alerts router', () => {
});
it('can silence an alert', async () => {
const { agent, user } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -187,6 +203,7 @@ describe('alerts router', () => {
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
@ -209,7 +226,6 @@ describe('alerts router', () => {
});
it('can unsilence an alert', async () => {
const { agent, user } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -220,6 +236,7 @@ describe('alerts router', () => {
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
@ -245,7 +262,6 @@ describe('alerts router', () => {
});
it('returns silenced info in GET /alerts', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -256,6 +272,7 @@ describe('alerts router', () => {
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: dashboard.body.tiles[0].id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
@ -278,7 +295,6 @@ describe('alerts router', () => {
});
it('prevents silencing an alert that does not exist', async () => {
const { agent } = await getLoggedInAgent(server);
const fakeId = randomMongoId();
const mutedUntil = new Date(Date.now() + 3600000).toISOString();
@ -289,7 +305,6 @@ describe('alerts router', () => {
});
it('prevents unsilencing an alert that does not exist', async () => {
const { agent } = await getLoggedInAgent(server);
const fakeId = randomMongoId();
await agent.delete(`/alerts/${fakeId}/silenced`).expect(404); // Should fail

View file

@ -10,6 +10,7 @@ import mongoose, { Types } from 'mongoose';
import PresetDashboardFilter from '@/models/presetDashboardFilter';
import { Source } from '@/models/source';
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
import {
getLoggedInAgent,
@ -25,20 +26,37 @@ const MOCK_DASHBOARD = {
tags: ['test'],
};
const MOCK_ALERT = {
channel: { type: 'webhook' as const, webhookId: 'abcde' },
const makeMockAlert = (webhookId: string) => ({
channel: { type: 'webhook' as const, webhookId },
interval: '12h' as const,
threshold: 1,
thresholdType: AlertThresholdType.ABOVE,
};
});
describe('dashboard router', () => {
const server = getServer();
let agent: Awaited<ReturnType<typeof getLoggedInAgent>>['agent'];
let team: Awaited<ReturnType<typeof getLoggedInAgent>>['team'];
let user: Awaited<ReturnType<typeof getLoggedInAgent>>['user'];
let webhook: WebhookDocument;
beforeAll(async () => {
await server.start();
});
beforeEach(async () => {
const result = await getLoggedInAgent(server);
agent = result.agent;
team = result.team;
user = result.user;
webhook = await Webhook.create({
name: 'Test Webhook',
service: WebhookService.Slack,
url: 'https://hooks.slack.com/test',
team: team._id,
});
});
afterEach(async () => {
await server.clearDBs();
});
@ -48,7 +66,6 @@ describe('dashboard router', () => {
});
it('can create a dashboard', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -61,7 +78,6 @@ describe('dashboard router', () => {
});
it('can update a dashboard', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -85,7 +101,6 @@ describe('dashboard router', () => {
});
it('can delete a dashboard', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -96,12 +111,12 @@ describe('dashboard router', () => {
});
it('alerts are created when creating dashboard', async () => {
const { agent } = await getLoggedInAgent(server);
const mockAlert = makeMockAlert(webhook._id.toString());
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [makeTile({ alert: MOCK_ALERT })],
tiles: [makeTile({ alert: mockAlert })],
tags: [],
})
.expect(200);
@ -109,14 +124,14 @@ describe('dashboard router', () => {
const alerts = await agent.get(`/alerts`).expect(200);
expect(alerts.body.data).toMatchObject([
{
...omit(MOCK_ALERT, 'channel.webhookId'),
...omit(mockAlert, 'channel.webhookId'),
tileId: dashboard.body.tiles[0].id,
},
]);
});
it('alerts are created when updating dashboard (adding alert to tile)', async () => {
const { agent } = await getLoggedInAgent(server);
const mockAlert = makeMockAlert(webhook._id.toString());
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
@ -126,26 +141,26 @@ describe('dashboard router', () => {
.patch(`/dashboards/${dashboard.body.id}`)
.send({
...dashboard.body,
tiles: [...dashboard.body.tiles, makeTile({ alert: MOCK_ALERT })],
tiles: [...dashboard.body.tiles, makeTile({ alert: mockAlert })],
})
.expect(200);
const alerts = await agent.get(`/alerts`).expect(200);
expect(alerts.body.data).toMatchObject([
{
...omit(MOCK_ALERT, 'channel.webhookId'),
...omit(mockAlert, 'channel.webhookId'),
tileId: updatedDashboard.body.tiles[MOCK_DASHBOARD.tiles.length].id,
},
]);
});
it('alerts are deleted when updating dashboard (deleting tile alert settings)', async () => {
const { agent } = await getLoggedInAgent(server);
const mockAlert = makeMockAlert(webhook._id.toString());
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [makeTile({ alert: MOCK_ALERT })],
tiles: [makeTile({ alert: mockAlert })],
tags: [],
})
.expect(200);
@ -163,12 +178,12 @@ describe('dashboard router', () => {
});
it('alerts are deleted when removing alert from tile (keeping tile)', async () => {
const { agent } = await getLoggedInAgent(server);
const mockAlert = makeMockAlert(webhook._id.toString());
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [makeTile({ alert: MOCK_ALERT })],
tiles: [makeTile({ alert: mockAlert })],
tags: [],
})
.expect(200);
@ -195,18 +210,18 @@ describe('dashboard router', () => {
});
it('alerts are updated when updating dashboard (updating tile alert settings)', async () => {
const { agent } = await getLoggedInAgent(server);
const mockAlert = makeMockAlert(webhook._id.toString());
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [makeTile({ alert: MOCK_ALERT })],
tiles: [makeTile({ alert: mockAlert })],
tags: [],
})
.expect(200);
const updatedAlert = {
...MOCK_ALERT,
...mockAlert,
threshold: 2,
};
@ -236,8 +251,6 @@ describe('dashboard router', () => {
});
it('deletes attached alerts when deleting tiles', async () => {
const { agent } = await getLoggedInAgent(server);
await agent.post('/dashboards').send(MOCK_DASHBOARD).expect(200);
const initialDashboards = await agent.get('/dashboards').expect(200);
@ -251,6 +264,7 @@ describe('dashboard router', () => {
makeAlertInput({
dashboardId: dashboard._id,
tileId: tile.id,
webhookId: webhook._id.toString(),
}),
)
.expect(200),
@ -295,14 +309,15 @@ describe('dashboard router', () => {
});
it('preserves alert creator when different user updates dashboard', async () => {
const { agent, user: currentUser } = await getLoggedInAgent(server);
const mockAlert = makeMockAlert(webhook._id.toString());
const currentUser = user;
// Arrange: Create dashboard with alert
const dashboardResponse = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [makeTile({ alert: MOCK_ALERT })],
tiles: [makeTile({ alert: mockAlert })],
tags: [],
})
.expect(200);
@ -325,7 +340,7 @@ describe('dashboard router', () => {
// Act: Current user updates the dashboard (modifies alert threshold)
const updatedThreshold = 5;
const updatedAlert = {
...MOCK_ALERT,
...mockAlert,
threshold: updatedThreshold,
};
@ -391,8 +406,6 @@ describe('dashboard router', () => {
describe('GET /preset/:presetDashboard/filters', () => {
it('returns preset dashboard filters for a given source', async () => {
const { agent, team } = await getLoggedInAgent(server);
// Create a test source
const source = await Source.create({
...MOCK_SOURCE,
@ -423,8 +436,6 @@ describe('dashboard router', () => {
});
it('returns empty array when no filters exist for source', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -439,16 +450,12 @@ describe('dashboard router', () => {
});
it('returns 400 when sourceId is missing', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.get(`/dashboards/preset/${PresetDashboard.Services}/filters`)
.expect(400);
});
it('returns 400 when sourceId is empty', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.get(`/dashboards/preset/${PresetDashboard.Services}/filters`)
.query({ sourceId: '' })
@ -456,8 +463,6 @@ describe('dashboard router', () => {
});
it('returns 400 for invalid preset dashboard type', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -470,12 +475,11 @@ describe('dashboard router', () => {
});
it('does not return filters from other teams in GET', async () => {
const { agent: agent1, team: team1 } = await getLoggedInAgent(server);
const team2 = new mongoose.Types.ObjectId();
const source1 = await Source.create({
...MOCK_SOURCE,
team: team1._id,
team: team._id,
});
const source2 = await Source.create({
@ -485,7 +489,7 @@ describe('dashboard router', () => {
await PresetDashboardFilter.create({
...MOCK_PRESET_DASHBOARD_FILTER,
team: team1._id,
team: team._id,
source: source1._id,
});
@ -495,20 +499,18 @@ describe('dashboard router', () => {
source: source2._id,
});
const response = await agent1
const response = await agent
.get(`/dashboards/preset/${PresetDashboard.Services}/filters`)
.query({ sourceId: source1._id.toString() })
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].team).toEqual(team1._id.toString());
expect(response.body[0].team).toEqual(team._id.toString());
});
});
describe('POST /preset/:presetDashboard/filter', () => {
it('creates a new preset dashboard filter', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -549,8 +551,6 @@ describe('dashboard router', () => {
});
it('creates filter with optional sourceMetricType', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -572,8 +572,6 @@ describe('dashboard router', () => {
});
it('returns 400 when filter preset dashboard does not match params', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -594,8 +592,6 @@ describe('dashboard router', () => {
});
it('returns 400 when filter is missing required fields', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -614,8 +610,6 @@ describe('dashboard router', () => {
});
it('returns 400 when filter body is missing', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.post(`/dashboards/preset/${PresetDashboard.Services}/filter`)
.send({})
@ -625,8 +619,6 @@ describe('dashboard router', () => {
describe('PUT /preset/:presetDashboard/filter', () => {
it('updates an existing preset dashboard filter', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -667,8 +659,6 @@ describe('dashboard router', () => {
});
it('returns an error when the filter does not exist', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -690,8 +680,6 @@ describe('dashboard router', () => {
});
it('updates filter with sourceMetricType', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -722,8 +710,6 @@ describe('dashboard router', () => {
});
it('returns 400 when filter preset dashboard does not match params', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -746,8 +732,6 @@ describe('dashboard router', () => {
});
it('returns 400 when filter is missing required fields', async () => {
const { agent } = await getLoggedInAgent(server);
const incompleteFilter = {
id: new Types.ObjectId().toString(),
name: 'Test Filter',
@ -763,8 +747,6 @@ describe('dashboard router', () => {
describe('DELETE /preset/:presetDashboard/filter/:id', () => {
it('deletes a preset dashboard filter', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
@ -793,8 +775,6 @@ describe('dashboard router', () => {
});
it('returns 404 when filter does not exist', async () => {
const { agent } = await getLoggedInAgent(server);
const nonExistentId = new Types.ObjectId().toString();
await agent
@ -805,16 +785,12 @@ describe('dashboard router', () => {
});
it('returns 400 when id is invalid', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.delete('/dashboards/preset/services/filter/invalid-id')
.expect(400);
});
it('returns 400 for invalid preset dashboard type', async () => {
const { agent } = await getLoggedInAgent(server);
const filterId = new Types.ObjectId().toString();
await agent
@ -823,7 +799,6 @@ describe('dashboard router', () => {
});
it('does not delete filters from other teams', async () => {
const { agent: agent } = await getLoggedInAgent(server); // team 1
const team2Id = new mongoose.Types.ObjectId();
const source = await Source.create({

View file

@ -4,6 +4,7 @@ import {
makeSavedSearchAlertInput,
} from '@/fixtures';
import Alert from '@/models/alert';
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
const MOCK_SAVED_SEARCH = {
name: 'error',
@ -17,11 +18,28 @@ const MOCK_SAVED_SEARCH = {
describe('savedSearch router', () => {
const server = getServer();
let agent: Awaited<ReturnType<typeof getLoggedInAgent>>['agent'];
let team: Awaited<ReturnType<typeof getLoggedInAgent>>['team'];
let user: Awaited<ReturnType<typeof getLoggedInAgent>>['user'];
let webhook: WebhookDocument;
beforeAll(async () => {
await server.start();
});
beforeEach(async () => {
const result = await getLoggedInAgent(server);
agent = result.agent;
team = result.team;
user = result.user;
webhook = await Webhook.create({
name: 'Test Webhook',
service: WebhookService.Slack,
url: 'https://hooks.slack.com/test',
team: team._id,
});
});
afterEach(async () => {
await server.clearDBs();
});
@ -31,7 +49,6 @@ describe('savedSearch router', () => {
});
it('can create a saved search', async () => {
const { agent } = await getLoggedInAgent(server);
const savedSearch = await agent
.post('/saved-search')
.send(MOCK_SAVED_SEARCH)
@ -41,7 +58,6 @@ describe('savedSearch router', () => {
});
it('cannot create a saved search with empty name', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.post('/saved-search')
.send({ ...MOCK_SAVED_SEARCH, name: ' ' }) // Trimmed string will be empty and invalid
@ -49,7 +65,6 @@ describe('savedSearch router', () => {
});
it('can update a saved search', async () => {
const { agent } = await getLoggedInAgent(server);
const savedSearch = await agent
.post('/saved-search')
.send(MOCK_SAVED_SEARCH)
@ -62,7 +77,6 @@ describe('savedSearch router', () => {
});
it('cannot update a saved search with empty name', async () => {
const { agent } = await getLoggedInAgent(server);
const savedSearch = await agent
.post('/saved-search')
.send(MOCK_SAVED_SEARCH)
@ -74,7 +88,6 @@ describe('savedSearch router', () => {
});
it('can update a saved search with undefined name', async () => {
const { agent } = await getLoggedInAgent(server);
const savedSearch = await agent
.post('/saved-search')
.send(MOCK_SAVED_SEARCH)
@ -87,7 +100,6 @@ describe('savedSearch router', () => {
});
it('can get saved searches', async () => {
const { agent } = await getLoggedInAgent(server);
await agent.post('/saved-search').send(MOCK_SAVED_SEARCH).expect(200);
const savedSearches = await agent.get('/saved-search').expect(200);
expect(savedSearches.body.length).toBe(1);
@ -95,7 +107,6 @@ describe('savedSearch router', () => {
});
it('can delete a saved search', async () => {
const { agent } = await getLoggedInAgent(server);
const savedSearch = await agent
.post('/saved-search')
.send(MOCK_SAVED_SEARCH)
@ -106,6 +117,7 @@ describe('savedSearch router', () => {
.send(
makeSavedSearchAlertInput({
savedSearchId: savedSearch.body._id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
@ -116,8 +128,6 @@ describe('savedSearch router', () => {
});
it('sets createdBy on alerts created from a saved search and populates it in list', async () => {
const { agent, user } = await getLoggedInAgent(server);
// Create a saved search
const savedSearch = await agent
.post('/saved-search')
@ -130,6 +140,7 @@ describe('savedSearch router', () => {
.send(
makeSavedSearchAlertInput({
savedSearchId: savedSearch.body._id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);

View file

@ -11,6 +11,7 @@ import {
getAlertById,
getAlertsEnhanced,
updateAlert,
validateAlertInput,
} from '@/controllers/alerts';
import { alertSchema, objectIdSchema } from '@/utils/zod';
@ -97,6 +98,7 @@ router.post(
}
try {
const alertInput = req.body;
await validateAlertInput(teamId, alertInput);
return res.json({
data: await createAlert(teamId, alertInput, userId),
});

View file

@ -6,6 +6,8 @@ import { getLoggedInAgent, getServer } from '../../../fixtures';
import { AlertSource, AlertThresholdType } from '../../../models/alert';
import Alert from '../../../models/alert';
import Dashboard from '../../../models/dashboard';
import { SavedSearch } from '../../../models/savedSearch';
import Webhook, { WebhookService } from '../../../models/webhook';
// Constants
const ALERTS_BASE_URL = '/api/v2/alerts';
@ -39,9 +41,27 @@ describe('External API Alerts', () => {
return agent[method](url).set('Authorization', `Bearer ${user?.accessKey}`);
};
// Helper to create a webhook for testing
const createTestWebhook = async (options: { teamId?: any } = {}) => {
return await Webhook.findOneAndUpdate(
{
name: 'Test Webhook',
service: WebhookService.Slack,
team: options.teamId ?? team._id,
},
{
name: 'Test Webhook',
service: WebhookService.Slack,
url: 'https://hooks.slack.com/test',
team: options.teamId ?? team._id,
},
{ upsert: true, new: true },
);
};
// Helper to create a dashboard for testing
const createTestDashboard = async (
options: { numTiles?: number; name?: string } = {},
options: { numTiles?: number; name?: string; teamId?: any } = {},
) => {
const { numTiles = 1, name = 'Test Dashboard' } = options;
@ -58,13 +78,25 @@ describe('External API Alerts', () => {
return new Dashboard({
name,
tiles,
team: team._id,
team: options.teamId ?? team._id,
}).save();
};
// Helper to create a saved search for testing
const createTestSavedSearch = async (options: { teamId?: any } = {}) => {
return new SavedSearch({
name: 'Test Saved Search',
where: 'error',
whereLanguage: 'lucene',
source: new ObjectId(),
team: options.teamId ?? team._id,
}).save();
};
// Helper to create a test alert via API
const createTestAlert = async (overrides = {}) => {
const dashboard = await createTestDashboard();
const webhook = await createTestWebhook();
const alertInput = {
dashboardId: dashboard._id.toString(),
@ -75,7 +107,7 @@ describe('External API Alerts', () => {
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: new ObjectId().toString(),
webhookId: webhook._id.toString(),
},
name: 'Test Alert',
message: 'Test Alert Message',
@ -89,11 +121,12 @@ describe('External API Alerts', () => {
return {
alert: response.body.data,
dashboard,
webhook,
alertInput,
};
};
// Helper to create a test alert directly in the database
// Helper to create a test alert directly in the database (bypasses validation)
const createTestAlertDirectly = async (overrides = {}) => {
return Alert.create({
team: team._id,
@ -115,8 +148,9 @@ describe('External API Alerts', () => {
describe('Response Format', () => {
it('should return responses in the expected format', async () => {
// Create a test alert with known values
// Create a test dashboard and webhook with known values
const testDashboard = await createTestDashboard();
const testWebhook = await createTestWebhook();
const testAlert = {
dashboardId: testDashboard._id.toString(),
tileId: testDashboard.tiles[0].id,
@ -126,7 +160,7 @@ describe('External API Alerts', () => {
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: new ObjectId().toString(),
webhookId: testWebhook._id.toString(),
},
name: 'Format Test Alert',
message: 'This is a test alert for format verification',
@ -197,11 +231,10 @@ describe('External API Alerts', () => {
describe('Creating alerts', () => {
it('should create an alert', async () => {
// Create a test dashboard
// Create a test dashboard and webhook
const dashboard = await createTestDashboard();
const webhook = await createTestWebhook();
// Create alert data
const webhookId = new ObjectId().toString();
const alertInput = {
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
@ -211,7 +244,7 @@ describe('External API Alerts', () => {
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhookId,
webhookId: webhook._id.toString(),
},
name: 'Test Alert',
message: 'Test Alert Message',
@ -256,6 +289,7 @@ describe('External API Alerts', () => {
it('should create multiple alerts for different tiles', async () => {
// Create a dashboard with multiple tiles
const dashboard = await createTestDashboard({ numTiles: 3 });
const webhook = await createTestWebhook();
// Store created alert IDs for verification
const createdAlertIds: string[] = [];
@ -271,7 +305,7 @@ describe('External API Alerts', () => {
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: new ObjectId().toString(),
webhookId: webhook._id.toString(),
},
name: `Alert for ${tile.id}`,
message: `This is an alert for ${tile.id}`,
@ -445,6 +479,253 @@ describe('External API Alerts', () => {
});
});
describe('Input validation', () => {
describe('webhook validation', () => {
it('should reject a non-existent webhook', async () => {
const dashboard = await createTestDashboard();
const alertInput = {
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: new ObjectId().toString(), // does not exist
},
};
await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
});
it('should reject a webhook belonging to another team', async () => {
const dashboard = await createTestDashboard();
const otherTeamWebhook = await createTestWebhook({
teamId: new ObjectId(),
});
const alertInput = {
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: otherTeamWebhook._id.toString(),
},
};
await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
});
it('should reject an update with a webhook belonging to another team', async () => {
const { alert, dashboard } = await createTestAlert();
const otherTeamWebhook = await createTestWebhook({
teamId: new ObjectId(),
});
const updatePayload = {
threshold: 200,
interval: '1h',
thresholdType: AlertThresholdType.ABOVE,
source: AlertSource.TILE,
dashboardId: dashboard._id.toString(),
tileId: dashboard.tiles[0].id,
channel: {
type: 'webhook',
webhookId: otherTeamWebhook._id.toString(),
},
};
await authRequest('put', `${ALERTS_BASE_URL}/${alert.id}`)
.send(updatePayload)
.expect(400);
});
});
describe('dashboard (TILE source) validation', () => {
it('should reject a non-existent dashboard', async () => {
const webhook = await createTestWebhook();
const alertInput = {
dashboardId: new ObjectId().toString(), // does not exist
tileId: new ObjectId().toString(),
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};
await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
});
it('should reject a dashboard belonging to another team', async () => {
const webhook = await createTestWebhook();
const otherTeamDashboard = await createTestDashboard({
teamId: new ObjectId(),
});
const alertInput = {
dashboardId: otherTeamDashboard._id.toString(),
tileId: otherTeamDashboard.tiles[0].id,
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};
await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
});
it('should reject an update with a dashboard belonging to another team', async () => {
const { alert, webhook } = await createTestAlert();
const otherTeamDashboard = await createTestDashboard({
teamId: new ObjectId(),
});
const updatePayload = {
threshold: 200,
interval: '1h',
thresholdType: AlertThresholdType.ABOVE,
source: AlertSource.TILE,
dashboardId: otherTeamDashboard._id.toString(),
tileId: otherTeamDashboard.tiles[0].id,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};
await authRequest('put', `${ALERTS_BASE_URL}/${alert.id}`)
.send(updatePayload)
.expect(400);
});
});
describe('saved search (SAVED_SEARCH source) validation', () => {
it('should reject a non-existent saved search', async () => {
const webhook = await createTestWebhook();
const alertInput = {
savedSearchId: new ObjectId().toString(), // does not exist
threshold: 100,
interval: '1h',
source: AlertSource.SAVED_SEARCH,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};
await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
});
it('should reject a saved search belonging to another team', async () => {
const webhook = await createTestWebhook();
const otherTeamSavedSearch = await createTestSavedSearch({
teamId: new ObjectId(),
});
const alertInput = {
savedSearchId: otherTeamSavedSearch._id.toString(),
threshold: 100,
interval: '1h',
source: AlertSource.SAVED_SEARCH,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};
await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
});
it('should create an alert with a valid saved search belonging to the team', async () => {
const webhook = await createTestWebhook();
const savedSearch = await createTestSavedSearch();
const alertInput = {
savedSearchId: savedSearch._id.toString(),
threshold: 100,
interval: '1h',
source: AlertSource.SAVED_SEARCH,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
name: 'Saved Search Alert',
};
const response = await authRequest('post', ALERTS_BASE_URL)
.send(alertInput)
.expect(200);
expect(response.body.data.source).toBe(AlertSource.SAVED_SEARCH);
expect(response.body.data.savedSearchId).toBe(
savedSearch._id.toString(),
);
});
it('should reject an update with a saved search belonging to another team', async () => {
const savedSearch = await createTestSavedSearch();
const webhook = await createTestWebhook();
// Create a saved search alert first
const createResponse = await authRequest('post', ALERTS_BASE_URL)
.send({
savedSearchId: savedSearch._id.toString(),
threshold: 100,
interval: '1h',
source: AlertSource.SAVED_SEARCH,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
})
.expect(200);
const otherTeamSavedSearch = await createTestSavedSearch({
teamId: new ObjectId(),
});
const updatePayload = {
savedSearchId: otherTeamSavedSearch._id.toString(),
threshold: 200,
interval: '1h',
source: AlertSource.SAVED_SEARCH,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};
await authRequest(
'put',
`${ALERTS_BASE_URL}/${createResponse.body.data.id}`,
)
.send(updatePayload)
.expect(400);
});
});
});
describe('Authentication', () => {
it('should require authentication', async () => {
// Create an unauthenticated agent

View file

@ -8,6 +8,7 @@ import {
getAlertById,
getAlerts,
updateAlert,
validateAlertInput,
} from '@/controllers/alerts';
import { validateRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors';
import { translateAlertDocumentToExternalAlert } from '@/utils/externalApi';
@ -404,7 +405,9 @@ router.post(
return res.sendStatus(403);
}
try {
const alertInput = req.body;
const alertInput = alertSchema.parse(req.body);
await validateAlertInput(teamId, alertInput);
const createdAlert = await createAlert(teamId, alertInput, userId);
return res.json({
@ -496,7 +499,9 @@ router.put(
}
const { id } = req.params;
const alertInput = req.body;
const alertInput = alertSchema.parse(req.body);
await validateAlertInput(teamId, alertInput);
const alert = await updateAlert(id, teamId, alertInput);
if (alert == null) {