diff --git a/packages/api/src/routers/external-api/__tests__/savedSearches.test.ts b/packages/api/src/routers/external-api/__tests__/savedSearches.test.ts new file mode 100644 index 0000000000..4a05192932 --- /dev/null +++ b/packages/api/src/routers/external-api/__tests__/savedSearches.test.ts @@ -0,0 +1,324 @@ +import { ObjectId } from 'mongodb'; +import request, { SuperAgentTest } from 'supertest'; + +import { getLoggedInAgent, getServer } from '@/fixtures'; +import { SavedSearch } from '@/models/savedSearch'; +import { Source } from '@/models/source'; +import { ITeam } from '@/models/team'; +import { IUser } from '@/models/user'; + +const BASE_URL = '/api/v2/saved-searches'; + +describe('External API v2 Saved Searches', () => { + const server = getServer(); + let agent: SuperAgentTest; + let team: ITeam; + let user: IUser; + let sourceId: string; + + beforeAll(async () => { + await server.start(); + }); + + beforeEach(async () => { + const result = await getLoggedInAgent(server); + agent = result.agent; + team = result.team; + user = result.user; + + // Create a source to use in tests + const source = await Source.create({ + team: team._id, + name: 'Test Source', + kind: 'log', + connection: new ObjectId(), + from: { databaseName: 'otel', tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', + }); + sourceId = source._id.toString(); + }); + + afterEach(async () => { + await server.clearDBs(); + }); + + afterAll(async () => { + await server.stop(); + }); + + const authRequest = (method: 'get' | 'post' | 'put' | 'delete', url: string) => { + return agent[method](url).set('Authorization', `Bearer ${user?.accessKey}`); + }; + + const mockSavedSearch = () => ({ + name: 'Test Saved Search', + sourceId, + where: 'SeverityText:ERROR', + whereLanguage: 'lucene' as const, + select: 'Timestamp,Body,ServiceName', + orderBy: 'Timestamp DESC', + tags: ['errors', 'production'], + }); + + describe('GET /api/v2/saved-searches', () => { + it('returns empty list when none exist', async () => { + const res = await authRequest('get', BASE_URL).expect(200); + expect(res.body).toEqual({ data: [] }); + }); + + it('lists saved searches for the team', async () => { + await SavedSearch.create({ + team: team._id, + source: new ObjectId(sourceId), + name: 'My Search', + where: 'level:error', + }); + + const res = await authRequest('get', BASE_URL).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0]).toMatchObject({ + id: expect.any(String), + name: 'My Search', + where: 'level:error', + teamId: team._id.toString(), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(res.body.data[0]).not.toHaveProperty('team'); + expect(res.body.data[0]).not.toHaveProperty('_id'); + }); + + it('does not return saved searches from another team', async () => { + await SavedSearch.create({ + team: team._id, + source: new ObjectId(sourceId), + name: 'My Search', + }); + await SavedSearch.create({ + team: new ObjectId(), + source: new ObjectId(), + name: 'Other Team Search', + }); + + const res = await authRequest('get', BASE_URL).expect(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].name).toBe('My Search'); + }); + + it('requires authentication', async () => { + await request(server.getHttpServer()).get(BASE_URL).expect(401); + }); + }); + + describe('GET /api/v2/saved-searches/:id', () => { + it('returns a saved search by id', async () => { + const doc = await SavedSearch.create({ + team: team._id, + source: new ObjectId(sourceId), + name: 'My Search', + where: 'level:error', + whereLanguage: 'lucene', + }); + + const res = await authRequest('get', `${BASE_URL}/${doc._id}`).expect(200); + expect(res.body.data).toMatchObject({ + id: doc._id.toString(), + name: 'My Search', + where: 'level:error', + whereLanguage: 'lucene', + }); + }); + + it('returns 404 for non-existent id', async () => { + await authRequest('get', `${BASE_URL}/${new ObjectId()}`).expect(404); + }); + + it('returns 404 for another team\'s saved search', async () => { + const doc = await SavedSearch.create({ + team: new ObjectId(), + source: new ObjectId(), + name: 'Other Team', + }); + await authRequest('get', `${BASE_URL}/${doc._id}`).expect(404); + }); + + it('returns 400 for invalid id', async () => { + await authRequest('get', `${BASE_URL}/not-an-id`).expect(400); + }); + + it('requires authentication', async () => { + await request(server.getHttpServer()) + .get(`${BASE_URL}/${new ObjectId()}`) + .expect(401); + }); + }); + + describe('POST /api/v2/saved-searches', () => { + it('creates a saved search', async () => { + const payload = mockSavedSearch(); + const res = await authRequest('post', BASE_URL).send(payload).expect(200); + + expect(res.body.data).toMatchObject({ + id: expect.any(String), + name: payload.name, + where: payload.where, + whereLanguage: payload.whereLanguage, + select: payload.select, + orderBy: payload.orderBy, + tags: payload.tags, + sourceId, + teamId: team._id.toString(), + }); + + const stored = await SavedSearch.findById(res.body.data.id); + expect(stored?.team.toString()).toBe(team._id.toString()); + }); + + it('creates a minimal saved search (name + sourceId only)', async () => { + const res = await authRequest('post', BASE_URL) + .send({ name: 'Minimal', sourceId }) + .expect(200); + expect(res.body.data.name).toBe('Minimal'); + }); + + it('rejects missing required fields', async () => { + await authRequest('post', BASE_URL).send({ name: 'No source' }).expect(400); + await authRequest('post', BASE_URL).send({ sourceId }).expect(400); + }); + + it('rejects a sourceId belonging to another team', async () => { + const otherSource = await Source.create({ + team: new ObjectId(), + name: 'Other Team Source', + kind: 'log', + connection: new ObjectId(), + from: { databaseName: 'otel', tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', + }); + await authRequest('post', BASE_URL) + .send({ name: 'Cross team', sourceId: otherSource._id.toString() }) + .expect(400); + }); + + it('rejects a non-existent sourceId', async () => { + await authRequest('post', BASE_URL) + .send({ name: 'Bad source', sourceId: new ObjectId().toString() }) + .expect(400); + }); + + it('requires authentication', async () => { + await request(server.getHttpServer()) + .post(BASE_URL) + .send(mockSavedSearch()) + .expect(401); + }); + }); + + describe('PUT /api/v2/saved-searches/:id', () => { + it('updates a saved search', async () => { + const doc = await SavedSearch.create({ + team: team._id, + source: new ObjectId(sourceId), + name: 'Original', + where: 'level:info', + }); + + const res = await authRequest('put', `${BASE_URL}/${doc._id}`) + .send({ name: 'Updated', sourceId, where: 'level:error' }) + .expect(200); + + expect(res.body.data).toMatchObject({ + id: doc._id.toString(), + name: 'Updated', + where: 'level:error', + }); + }); + + it('returns 404 for non-existent id', async () => { + await authRequest('put', `${BASE_URL}/${new ObjectId()}`) + .send(mockSavedSearch()) + .expect(404); + }); + + it('returns 404 for another team\'s saved search', async () => { + const doc = await SavedSearch.create({ + team: new ObjectId(), + source: new ObjectId(), + name: 'Other', + }); + await authRequest('put', `${BASE_URL}/${doc._id}`) + .send(mockSavedSearch()) + .expect(404); + }); + + it('rejects missing required fields', async () => { + const doc = await SavedSearch.create({ + team: team._id, + source: new ObjectId(sourceId), + name: 'My Search', + }); + await authRequest('put', `${BASE_URL}/${doc._id}`) + .send({ name: 'No source' }) + .expect(400); + }); + + it('rejects a sourceId belonging to another team', async () => { + const doc = await SavedSearch.create({ + team: team._id, + source: new ObjectId(sourceId), + name: 'My Search', + }); + const otherSource = await Source.create({ + team: new ObjectId(), + name: 'Other Team Source', + kind: 'log', + connection: new ObjectId(), + from: { databaseName: 'otel', tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', + }); + await authRequest('put', `${BASE_URL}/${doc._id}`) + .send({ name: 'Updated', sourceId: otherSource._id.toString() }) + .expect(400); + }); + + it('requires authentication', async () => { + await request(server.getHttpServer()) + .put(`${BASE_URL}/${new ObjectId()}`) + .send(mockSavedSearch()) + .expect(401); + }); + }); + + describe('DELETE /api/v2/saved-searches/:id', () => { + it('deletes a saved search', async () => { + const doc = await SavedSearch.create({ + team: team._id, + source: new ObjectId(sourceId), + name: 'To Delete', + }); + + await authRequest('delete', `${BASE_URL}/${doc._id}`).expect(200); + expect(await SavedSearch.findById(doc._id)).toBeNull(); + }); + + it('returns 404 for non-existent id', async () => { + await authRequest('delete', `${BASE_URL}/${new ObjectId()}`).expect(404); + }); + + it('does not delete another team\'s saved search', async () => { + const doc = await SavedSearch.create({ + team: new ObjectId(), + source: new ObjectId(), + name: 'Other', + }); + await authRequest('delete', `${BASE_URL}/${doc._id}`).expect(404); + expect(await SavedSearch.findById(doc._id)).not.toBeNull(); + }); + + it('requires authentication', async () => { + await request(server.getHttpServer()) + .delete(`${BASE_URL}/${new ObjectId()}`) + .expect(401); + }); + }); +}); diff --git a/packages/api/src/routers/external-api/v2/index.ts b/packages/api/src/routers/external-api/v2/index.ts index bf3adbce73..7fa8d08a35 100644 --- a/packages/api/src/routers/external-api/v2/index.ts +++ b/packages/api/src/routers/external-api/v2/index.ts @@ -5,6 +5,7 @@ import alertsRouter from '@/routers/external-api/v2/alerts'; import chartsRouter from '@/routers/external-api/v2/charts'; import connectionsRouter from '@/routers/external-api/v2/connections'; import dashboardRouter from '@/routers/external-api/v2/dashboards'; +import savedSearchesRouter from '@/routers/external-api/v2/savedSearches'; import searchRouter from '@/routers/external-api/v2/search'; import sourcesRouter from '@/routers/external-api/v2/sources'; import teamRouter from '@/routers/external-api/v2/team'; @@ -54,6 +55,12 @@ router.use( ); router.use('/search', defaultRateLimiter, validateUserAccessKey, searchRouter); +router.use( + '/saved-searches', + defaultRateLimiter, + validateUserAccessKey, + savedSearchesRouter, +); router.use( '/webhooks', diff --git a/packages/api/src/routers/external-api/v2/savedSearches.ts b/packages/api/src/routers/external-api/v2/savedSearches.ts new file mode 100644 index 0000000000..8d9005ddfe --- /dev/null +++ b/packages/api/src/routers/external-api/v2/savedSearches.ts @@ -0,0 +1,263 @@ +import express from 'express'; +import { z } from 'zod'; + +import { SavedSearch } from '@/models/savedSearch'; +import { Source } from '@/models/source'; +import { processRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors'; +import { objectIdSchema } from '@/utils/zod'; + +const bodySchema = z.object({ + name: z.string().min(1).max(1024), + sourceId: objectIdSchema, + where: z.string().max(8192).optional(), + whereLanguage: z.enum(['lucene', 'sql']).optional(), + select: z.string().max(4096).optional(), + orderBy: z.string().max(1024).optional(), + tags: z.array(z.string().max(32)).max(50).optional(), +}); + +const router = express.Router(); + +/** + * @openapi + * /api/v2/saved-searches: + * get: + * summary: List Saved Searches + * description: Retrieves a list of all saved searches for the authenticated team + * operationId: listSavedSearches + * tags: [Saved Searches] + * responses: + * '200': + * description: Successfully retrieved saved searches + * '401': + * description: Unauthorized + */ +router.get('/', async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) return res.sendStatus(403); + + const results = await SavedSearch.find({ team: teamId }).sort({ name: 1 }); + res.json({ data: results.map(s => s.toExternalJSON()) }); + } catch (e) { + next(e); + } +}); + +/** + * @openapi + * /api/v2/saved-searches/{id}: + * get: + * summary: Get Saved Search + * description: Retrieves a specific saved search by ID + * operationId: getSavedSearch + * tags: [Saved Searches] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: Saved Search ID + * responses: + * '200': + * description: Successfully retrieved saved search + * '401': + * description: Unauthorized + * '404': + * description: Saved search not found + */ +router.get( + '/:id', + validateRequest({ params: z.object({ id: objectIdSchema }) }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) return res.sendStatus(403); + + const doc = await SavedSearch.findOne({ + _id: req.params.id, + team: teamId, + }); + if (doc == null) return res.sendStatus(404); + + res.json({ data: doc.toExternalJSON() }); + } catch (e) { + next(e); + } + }, +); + +/** + * @openapi + * /api/v2/saved-searches: + * post: + * summary: Create Saved Search + * description: Creates a new saved search + * operationId: createSavedSearch + * tags: [Saved Searches] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateSavedSearchRequest' + * responses: + * '200': + * description: Successfully created saved search + * '400': + * description: Bad request + * '401': + * description: Unauthorized + */ +router.post( + '/', + validateRequest({ body: bodySchema }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) return res.sendStatus(403); + + const { name, sourceId, where, whereLanguage, select, orderBy, tags } = + req.body; + + const source = await Source.findOne({ _id: sourceId, team: teamId }); + if (source == null) { + return res.status(400).json({ error: 'sourceId not found' }); + } + + const doc = await new SavedSearch({ + team: teamId, + source: sourceId, + name, + where, + whereLanguage, + select, + orderBy, + tags, + }).save(); + + res.json({ data: doc.toExternalJSON() }); + } catch (e) { + next(e); + } + }, +); + +/** + * @openapi + * /api/v2/saved-searches/{id}: + * put: + * summary: Update Saved Search + * description: Updates an existing saved search + * operationId: updateSavedSearch + * tags: [Saved Searches] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: Saved Search ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateSavedSearchRequest' + * responses: + * '200': + * description: Successfully updated saved search + * '401': + * description: Unauthorized + * '404': + * description: Saved search not found + */ +router.put( + '/:id', + validateRequest({ + params: z.object({ id: objectIdSchema }), + body: bodySchema, + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) return res.sendStatus(403); + + const { name, sourceId, where, whereLanguage, select, orderBy, tags } = + req.body; + + const source = await Source.findOne({ _id: sourceId, team: teamId }); + if (source == null) { + return res.status(400).json({ error: 'sourceId not found' }); + } + + const doc = await SavedSearch.findOneAndUpdate( + { _id: req.params.id, team: teamId }, + { + $set: { + name, + source: sourceId, + where, + whereLanguage, + select, + orderBy, + tags, + }, + }, + { new: true }, + ); + if (doc == null) return res.sendStatus(404); + + res.json({ data: doc.toExternalJSON() }); + } catch (e) { + next(e); + } + }, +); + +/** + * @openapi + * /api/v2/saved-searches/{id}: + * delete: + * summary: Delete Saved Search + * description: Deletes a saved search + * operationId: deleteSavedSearch + * tags: [Saved Searches] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * description: Saved Search ID + * responses: + * '200': + * description: Successfully deleted saved search + * '401': + * description: Unauthorized + * '404': + * description: Saved search not found + */ +router.delete( + '/:id', + validateRequest({ params: z.object({ id: objectIdSchema }) }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) return res.sendStatus(403); + + const doc = await SavedSearch.findOneAndDelete({ + _id: req.params.id, + team: teamId, + }); + if (doc == null) return res.sendStatus(404); + + res.json({}); + } catch (e) { + next(e); + } + }, +); + +export default router;