Skip to content

Commit efe0f08

Browse files
authored
fix(stack): enforce max stack items and align public API limit (#3578)
1 parent e4dc0da commit efe0f08

File tree

8 files changed

+131
-8
lines changed

8 files changed

+131
-8
lines changed

__tests__/routes/public/stack.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import request from 'supertest';
22
import { setupPublicApiTests, createTokenForUser } from './helpers';
33
import { DatasetTool } from '../../../src/entity/dataset/DatasetTool';
44
import { UserStack } from '../../../src/entity/user/UserStack';
5+
import { MAX_STACK_ITEMS } from '../../../src/common/constants';
56

67
const state = setupPublicApiTests();
78

@@ -107,6 +108,35 @@ describe('GET /public/v1/profile/stack', () => {
107108

108109
expect(body.data.length).toBeLessThanOrEqual(5);
109110
});
111+
112+
it('should allow fetching up to maximum stack items', async () => {
113+
const token = await createTokenForUser(state.con, '5');
114+
115+
const tools = await state.con.getRepository(DatasetTool).save(
116+
Array.from({ length: 60 }, (_, index) => ({
117+
title: `Public Stack Tool ${index}`,
118+
titleNormalized: `publicstacktool${index}`,
119+
faviconSource: 'none',
120+
})),
121+
);
122+
123+
await state.con.getRepository(UserStack).save(
124+
tools.map((tool, index) => ({
125+
userId: '5',
126+
toolId: tool.id,
127+
section: 'primary',
128+
position: index,
129+
})),
130+
);
131+
132+
const { body } = await request(state.app.server)
133+
.get('/public/v1/profile/stack')
134+
.query({ limit: MAX_STACK_ITEMS })
135+
.set('Authorization', `Bearer ${token}`)
136+
.expect(200);
137+
138+
expect(body.data).toHaveLength(60);
139+
});
110140
});
111141

112142
describe('POST /public/v1/profile/stack', () => {

__tests__/sourceStack.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { DatasetTool } from '../src/entity/dataset/DatasetTool';
1515
import { Source, SourceMember } from '../src/entity';
1616
import { SourceMemberRoles } from '../src/roles';
1717
import { sourcesFixture } from './fixture/source';
18+
import { MAX_STACK_ITEMS } from '../src/common/constants';
1819

1920
let con: DataSource;
2021
let state: GraphQLTestingState;
@@ -242,6 +243,44 @@ describe('mutation addSourceStack', () => {
242243

243244
expect(res.errors?.[0]?.message).toBe('Stack can only be added to Squads');
244245
});
246+
247+
it('should prevent adding more than maximum stack items to squad', async () => {
248+
loggedUser = '1';
249+
await con.getRepository(SourceMember).save({
250+
userId: '1',
251+
sourceId: 'squad',
252+
role: SourceMemberRoles.Admin,
253+
referralToken: 'token1',
254+
});
255+
256+
const tools = await con.getRepository(DatasetTool).save(
257+
Array.from({ length: MAX_STACK_ITEMS }, (_, index) => ({
258+
title: `Squad Tool ${index}`,
259+
titleNormalized: `squadtool${index}`,
260+
faviconSource: 'none',
261+
})),
262+
);
263+
264+
await con.getRepository(SourceStack).save(
265+
tools.map((tool, index) => ({
266+
sourceId: 'squad',
267+
toolId: tool.id,
268+
position: index,
269+
createdById: '1',
270+
})),
271+
);
272+
273+
const res = await client.mutate(MUTATION, {
274+
variables: {
275+
sourceId: 'squad',
276+
input: { title: 'Node.js' },
277+
},
278+
});
279+
280+
expect(res.errors?.[0]?.message).toBe(
281+
`Squads can have a maximum of ${MAX_STACK_ITEMS} items in their stack`,
282+
);
283+
});
245284
});
246285

247286
describe('mutation updateSourceStack', () => {

__tests__/userStack.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { User } from '../src/entity/user/User';
1212
import { usersFixture } from './fixture/user';
1313
import { UserStack } from '../src/entity/user/UserStack';
1414
import { DatasetTool } from '../src/entity/dataset/DatasetTool';
15+
import { MAX_STACK_ITEMS } from '../src/common/constants';
1516

1617
let con: DataSource;
1718
let state: GraphQLTestingState;
@@ -150,6 +151,35 @@ describe('mutation addUserStack', () => {
150151
'Stack item already exists in your profile',
151152
);
152153
});
154+
155+
it('should prevent adding more than maximum stack items', async () => {
156+
loggedUser = '1';
157+
158+
const tools = await con.getRepository(DatasetTool).save(
159+
Array.from({ length: MAX_STACK_ITEMS }, (_, index) => ({
160+
title: `Tool ${index}`,
161+
titleNormalized: `tool${index}`,
162+
faviconSource: 'none',
163+
})),
164+
);
165+
166+
await con.getRepository(UserStack).save(
167+
tools.map((tool, index) => ({
168+
userId: '1',
169+
toolId: tool.id,
170+
section: 'Runtime',
171+
position: index,
172+
})),
173+
);
174+
175+
const res = await client.mutate(MUTATION, {
176+
variables: { input: { title: 'Node.js', section: 'Runtime' } },
177+
});
178+
179+
expect(res.errors?.[0]?.message).toBe(
180+
`You can have a maximum of ${MAX_STACK_ITEMS} items in your stack`,
181+
);
182+
});
153183
});
154184

155185
describe('mutation updateUserStack', () => {

src/common/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ export const coresBalanceExpirationSeconds = 60 * ONE_MINUTE_IN_SECONDS;
2020

2121
// Position value for newly added items (tools, stack, hot takes)
2222
export const NEW_ITEM_POSITION = 999999;
23+
export const MAX_STACK_ITEMS = 100;
2324

2425
export const PUBLIC_API_PREFIX = '/public/v1';

src/routes/public/common.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,15 @@ export interface FeedConnection<T> {
132132
/**
133133
* Parse and validate a limit query parameter.
134134
* @param limitParam - The limit query string parameter
135-
* @returns A valid limit between 1 and MAX_LIMIT
135+
* @param maxLimit - The maximum limit allowed for this endpoint
136+
* @returns A valid limit between 1 and maxLimit
136137
*/
137-
export const parseLimit = (limitParam?: string): number => {
138+
export const parseLimit = (
139+
limitParam?: string,
140+
maxLimit: number = MAX_LIMIT,
141+
): number => {
138142
const parsed = parseInt(limitParam || '', 10) || DEFAULT_LIMIT;
139-
return Math.min(Math.max(1, parsed), MAX_LIMIT);
143+
return Math.min(Math.max(1, parsed), maxLimit);
140144
};
141145

142146
/**

src/routes/public/stack.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
StackConnection,
1111
ToolType,
1212
} from './common';
13+
import { MAX_STACK_ITEMS } from '../../common/constants';
1314

1415
// GraphQL queries
1516
const AUTOCOMPLETE_TOOLS_QUERY = `
@@ -159,9 +160,9 @@ export default async function (fastify: FastifyInstance): Promise<void> {
159160
limit: {
160161
type: 'integer',
161162
default: 20,
162-
maximum: 50,
163+
maximum: MAX_STACK_ITEMS,
163164
minimum: 1,
164-
description: 'Number of items to return (1-50)',
165+
description: `Number of items to return (1-${MAX_STACK_ITEMS})`,
165166
},
166167
cursor: {
167168
type: 'string',
@@ -183,7 +184,7 @@ export default async function (fastify: FastifyInstance): Promise<void> {
183184
},
184185
},
185186
async (request, reply) => {
186-
const limit = parseLimit(request.query.limit);
187+
const limit = parseLimit(request.query.limit, MAX_STACK_ITEMS);
187188
const { cursor } = request.query;
188189
const con = ensureDbConnection(fastify.con);
189190
const userId = request.userId;

src/schema/sourceStack.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
type ReorderSourceStackInput,
1515
} from '../common/schema/sourceStack';
1616
import { findOrCreateDatasetTool } from '../common/datasetTool';
17-
import { NEW_ITEM_POSITION } from '../common/constants';
17+
import { MAX_STACK_ITEMS, NEW_ITEM_POSITION } from '../common/constants';
1818
import { ensureSourcePermissions, SourcePermissions } from './sources';
1919
import { Source, SourceType } from '../entity/Source';
2020

@@ -185,6 +185,15 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
185185
throw new ValidationError('Stack item already exists in this Squad');
186186
}
187187

188+
const count = await ctx.con.getRepository(SourceStack).count({
189+
where: { sourceId: source.id },
190+
});
191+
if (count >= MAX_STACK_ITEMS) {
192+
throw new ValidationError(
193+
`Squads can have a maximum of ${MAX_STACK_ITEMS} items in their stack`,
194+
);
195+
}
196+
188197
const sourceStack = ctx.con.getRepository(SourceStack).create({
189198
sourceId: source.id,
190199
toolId: datasetTool.id,

src/schema/userStack.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
type ReorderUserStackInput,
1515
} from '../common/schema/userStack';
1616
import { findOrCreateDatasetTool } from '../common/datasetTool';
17-
import { NEW_ITEM_POSITION } from '../common/constants';
17+
import { MAX_STACK_ITEMS, NEW_ITEM_POSITION } from '../common/constants';
1818

1919
interface GQLUserStack {
2020
id: string;
@@ -161,6 +161,15 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
161161
throw new ValidationError('Stack item already exists in your profile');
162162
}
163163

164+
const count = await ctx.con.getRepository(UserStack).count({
165+
where: { userId: ctx.userId },
166+
});
167+
if (count >= MAX_STACK_ITEMS) {
168+
throw new ValidationError(
169+
`You can have a maximum of ${MAX_STACK_ITEMS} items in your stack`,
170+
);
171+
}
172+
164173
const userStack = ctx.con.getRepository(UserStack).create({
165174
userId: ctx.userId,
166175
toolId: datasetTool.id,

0 commit comments

Comments
 (0)