fix: query settings length validation (#1890)

## Summary

Fixes an issue with query settings length validation. `maxlength` only works for strings, so the max amount of query settings (10) was never enforced by mongoose.

Adds a small validator to address this.

### How to test locally or on Vercel
The max length is already enforced in the app, so make a HTTP request directly to API - or trust the integration tests :)
This commit is contained in:
Karl Power 2026-03-16 16:05:54 +01:00 committed by GitHub
parent 8938b2741b
commit e09c8c0e5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 161 additions and 10 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/api": patch
"@hyperdx/common-utils": patch
---
fix: query settings length validation

View file

@ -1,5 +1,6 @@
import {
MetricsDataType,
QuerySettings,
SourceKind,
TSource,
} from '@hyperdx/common-utils/dist/types';
@ -14,6 +15,27 @@ export interface ISource extends Omit<TSource, 'connection'> {
export type SourceDocument = mongoose.HydratedDocument<ISource>;
const maxLength =
(max: number) =>
<T>({ length }: Array<T>) =>
length <= max;
const QuerySetting = new Schema<QuerySettings[number]>(
{
setting: {
type: String,
required: true,
minlength: 1,
},
value: {
type: String,
required: true,
minlength: 1,
},
},
{ _id: false },
);
export const Source = mongoose.model<ISource>(
'Source',
new Schema<ISource>(
@ -89,13 +111,11 @@ export const Source = mongoose.model<ISource>(
},
querySettings: {
type: [
{
setting: { type: String, required: true, minlength: 1 },
value: { type: String, required: true, minlength: 1 },
},
],
maxlength: 10,
type: [QuerySetting],
validate: {
validator: maxLength(10),
message: '{PATH} exceeds the limit of 10',
},
},
},
{

View file

@ -89,6 +89,131 @@ describe('sources router', () => {
.expect(400);
});
describe('querySettings validation', () => {
it('POST / - accepts and persists valid querySettings', async () => {
const { agent } = await getLoggedInAgent(server);
const querySettings = [
{ setting: 'max_execution_time', value: '60' },
{ setting: 'max_memory_usage', value: '10000000000' },
];
const response = await agent
.post('/sources')
.send({ ...MOCK_SOURCE, querySettings })
.expect(200);
expect(response.body.querySettings).toEqual(querySettings);
const sources = await Source.find({}).lean();
expect(sources).toHaveLength(1);
expect(sources[0]?.querySettings).toEqual(querySettings);
});
it('POST / - accepts querySettings at the limit of 10 items', async () => {
const { agent } = await getLoggedInAgent(server);
const querySettings = Array.from({ length: 10 }, (_, i) => ({
setting: `setting_${i}`,
value: `value_${i}`,
}));
const response = await agent
.post('/sources')
.send({ ...MOCK_SOURCE, querySettings })
.expect(200);
expect(response.body.querySettings).toHaveLength(10);
const sources = await Source.find({}).lean();
expect(sources[0]?.querySettings).toHaveLength(10);
});
it('POST / - rejects querySettings exceeding the limit of 10', async () => {
const { agent } = await getLoggedInAgent(server);
const querySettings = Array.from({ length: 11 }, (_, i) => ({
setting: `setting_${i}`,
value: `value_${i}`,
}));
const response = await agent
.post('/sources')
.send({ ...MOCK_SOURCE, querySettings });
expect(response.status).toBe(400);
const sources = await Source.find({}).lean();
expect(sources).toHaveLength(0);
});
it('POST / - returns 400 when querySettings item has empty setting or value', async () => {
const { agent } = await getLoggedInAgent(server);
await agent
.post('/sources')
.send({
...MOCK_SOURCE,
querySettings: [{ setting: '', value: 'x' }],
})
.expect(400);
await agent
.post('/sources')
.send({
...MOCK_SOURCE,
querySettings: [{ setting: 'x', value: '' }],
})
.expect(400);
});
it('PUT /:id - accepts and persists valid querySettings', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
const querySettings = [{ setting: 'max_execution_time', value: '120' }];
await agent
.put(`/sources/${source._id}`)
.send({
...MOCK_SOURCE,
id: source._id.toString(),
querySettings,
})
.expect(200);
const updated = await Source.findById(source._id).lean();
expect(updated?.querySettings).toEqual(querySettings);
});
it('PUT /:id - rejects querySettings exceeding the limit of 10', async () => {
const { agent, team } = await getLoggedInAgent(server);
const source = await Source.create({
...MOCK_SOURCE,
team: team._id,
});
const querySettings = Array.from({ length: 11 }, (_, i) => ({
setting: `setting_${i}`,
value: `value_${i}`,
}));
const response = await agent.put(`/sources/${source._id}`).send({
...MOCK_SOURCE,
id: source._id.toString(),
querySettings,
});
expect(response.status).toBe(400);
const updated = await Source.findById(source._id).lean();
expect(updated?.querySettings).toEqual([]); // defaults to [] when source created
});
});
it('PUT /:id - updates an existing source', async () => {
const { agent, team } = await getLoggedInAgent(server);

View file

@ -785,9 +785,9 @@ export enum SourceKind {
// TABLE SOURCE FORM VALIDATION
// --------------------------
const QuerySettingsSchema = z.array(
z.object({ setting: z.string().min(1), value: z.string().min(1) }),
);
const QuerySettingsSchema = z
.array(z.object({ setting: z.string().min(1), value: z.string().min(1) }))
.max(10);
export type QuerySettings = z.infer<typeof QuerySettingsSchema>;