@shipfox/api-projects 9.3.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +38 -0
  3. package/dist/db/index.d.ts +2 -2
  4. package/dist/db/index.d.ts.map +1 -1
  5. package/dist/db/index.js +1 -1
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/db/projects.d.ts +18 -0
  8. package/dist/db/projects.d.ts.map +1 -1
  9. package/dist/db/projects.js +45 -5
  10. package/dist/db/projects.js.map +1 -1
  11. package/dist/index.d.ts +4 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/presentation/dto/index.d.ts +1 -1
  16. package/dist/presentation/dto/index.d.ts.map +1 -1
  17. package/dist/presentation/dto/index.js +1 -1
  18. package/dist/presentation/dto/index.js.map +1 -1
  19. package/dist/presentation/dto/project.d.ts +9 -0
  20. package/dist/presentation/dto/project.d.ts.map +1 -1
  21. package/dist/presentation/dto/project.js +10 -0
  22. package/dist/presentation/dto/project.js.map +1 -1
  23. package/dist/presentation/inter-module.d.ts.map +1 -1
  24. package/dist/presentation/inter-module.js +7 -2
  25. package/dist/presentation/inter-module.js.map +1 -1
  26. package/dist/presentation/routes/admin-projects.d.ts +3 -0
  27. package/dist/presentation/routes/admin-projects.d.ts.map +1 -0
  28. package/dist/presentation/routes/admin-projects.js +61 -0
  29. package/dist/presentation/routes/admin-projects.js.map +1 -0
  30. package/dist/presentation/routes/index.d.ts +2 -1
  31. package/dist/presentation/routes/index.d.ts.map +1 -1
  32. package/dist/presentation/routes/index.js +8 -1
  33. package/dist/presentation/routes/index.js.map +1 -1
  34. package/dist/tsconfig.test.tsbuildinfo +1 -1
  35. package/package.json +6 -5
  36. package/src/db/index.ts +5 -0
  37. package/src/db/projects.test.ts +9 -1
  38. package/src/db/projects.ts +73 -6
  39. package/src/index.ts +8 -2
  40. package/src/presentation/dto/index.ts +1 -1
  41. package/src/presentation/dto/project.ts +12 -0
  42. package/src/presentation/inter-module.ts +4 -1
  43. package/src/presentation/routes/admin-projects.ts +66 -0
  44. package/src/presentation/routes/index.ts +10 -1
  45. package/src/presentation/routes/projects.test.ts +173 -1
  46. package/src/presentation/subscribers/on-source-commit-pushed.test.ts +5 -1
  47. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-projects",
3
3
  "license": "MIT",
4
- "version": "9.3.0",
4
+ "version": "10.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -21,13 +21,14 @@
21
21
  "@temporalio/workflow": "1.18.1",
22
22
  "drizzle-orm": "^0.45.2",
23
23
  "zod": "^4.4.3",
24
- "@shipfox/api-auth-context": "9.3.0",
24
+ "@shipfox/api-auth-dto": "10.1.0",
25
+ "@shipfox/api-auth-context": "10.1.0",
25
26
  "@shipfox/api-integration-core-dto": "9.0.2",
26
- "@shipfox/api-projects-dto": "9.2.0",
27
+ "@shipfox/api-projects-dto": "10.1.0",
27
28
  "@shipfox/inter-module": "0.2.2",
28
29
  "@shipfox/node-drizzle": "0.3.4",
29
- "@shipfox/node-fastify": "0.3.4",
30
- "@shipfox/node-module": "1.0.3",
30
+ "@shipfox/node-fastify": "0.4.0",
31
+ "@shipfox/node-module": "1.0.4",
31
32
  "@shipfox/node-opentelemetry": "0.6.3",
32
33
  "@shipfox/node-outbox": "0.2.6",
33
34
  "@shipfox/node-postgres": "0.4.4",
package/src/db/index.ts CHANGED
@@ -7,8 +7,11 @@ export {
7
7
  recordIntegrationEventForProject,
8
8
  } from './integration-event-dedup.js';
9
9
  export type {
10
+ AdminProjectSummary,
10
11
  CreateProjectParams,
11
12
  GetProjectBySourceParams,
13
+ ListAdminProjectsParams,
14
+ ListAdminProjectsResult,
12
15
  ListProjectsParams,
13
16
  ListProjectsResult,
14
17
  } from './projects.js';
@@ -17,6 +20,8 @@ export {
17
20
  getProjectById,
18
21
  getProjectBySource,
19
22
  getProjectCount,
23
+ getWorkspaceProjectCounts,
24
+ listAdminProjects,
20
25
  listProjects,
21
26
  requireProjectForWorkspace,
22
27
  } from './projects.js';
@@ -1,5 +1,5 @@
1
1
  import {projectFactory} from '#test/index.js';
2
- import {getProjectCount} from './projects.js';
2
+ import {getProjectCount, getWorkspaceProjectCounts} from './projects.js';
3
3
 
4
4
  describe('getProjectCount', () => {
5
5
  it('reports the current project count', async () => {
@@ -19,4 +19,12 @@ describe('getProjectCount', () => {
19
19
 
20
20
  expect(after - before).toBe(2);
21
21
  });
22
+
23
+ it('returns zero for workspaces without projects', async () => {
24
+ const workspaceId = crypto.randomUUID();
25
+
26
+ const counts = await getWorkspaceProjectCounts({workspaceIds: [workspaceId]});
27
+
28
+ expect(counts).toEqual([{workspaceId, count: 0}]);
29
+ });
22
30
  });
@@ -1,4 +1,4 @@
1
- import {and, count, desc, eq, ilike, lt, or, type SQL} from 'drizzle-orm';
1
+ import {and, count, desc, eq, ilike, inArray, lt, or, type SQL} from 'drizzle-orm';
2
2
  import type {Project} from '#core/entities/project.js';
3
3
  import {ProjectAlreadyExistsError, ProjectNotFoundError} from '#core/errors.js';
4
4
  import {recordProjectCreated} from '#metrics/instance.js';
@@ -33,11 +33,28 @@ export interface ListProjectsResult {
33
33
  nextCursor: ProjectCursor | null;
34
34
  }
35
35
 
36
- function cursorWhere(params: ListProjectsParams): SQL | undefined {
37
- if (!params.cursor) return undefined;
36
+ export type AdminProjectSummary = Pick<
37
+ Project,
38
+ 'id' | 'workspaceId' | 'name' | 'createdAt' | 'updatedAt'
39
+ >;
40
+
41
+ export interface ListAdminProjectsParams {
42
+ limit: number;
43
+ cursor?: ProjectCursor | undefined;
44
+ projectId?: string | undefined;
45
+ search?: string | undefined;
46
+ }
47
+
48
+ export interface ListAdminProjectsResult {
49
+ projects: AdminProjectSummary[];
50
+ nextCursor: ProjectCursor | null;
51
+ }
52
+
53
+ function cursorWhere(cursor: ProjectCursor | undefined): SQL | undefined {
54
+ if (!cursor) return undefined;
38
55
  return or(
39
- lt(projects.createdAt, params.cursor.createdAt),
40
- and(eq(projects.createdAt, params.cursor.createdAt), lt(projects.id, params.cursor.id)),
56
+ lt(projects.createdAt, cursor.createdAt),
57
+ and(eq(projects.createdAt, cursor.createdAt), lt(projects.id, cursor.id)),
41
58
  );
42
59
  }
43
60
 
@@ -129,7 +146,7 @@ export async function requireProjectForWorkspace(params: {
129
146
 
130
147
  export async function listProjects(params: ListProjectsParams): Promise<ListProjectsResult> {
131
148
  const conditions = [eq(projects.workspaceId, params.workspaceId)];
132
- const cursorCondition = cursorWhere(params);
149
+ const cursorCondition = cursorWhere(params.cursor);
133
150
  if (cursorCondition) conditions.push(cursorCondition);
134
151
  if (params.search) {
135
152
  conditions.push(ilike(projects.name, `%${escapeIlikePattern(params.search)}%`));
@@ -152,7 +169,57 @@ export async function listProjects(params: ListProjectsParams): Promise<ListProj
152
169
  };
153
170
  }
154
171
 
172
+ export async function listAdminProjects(
173
+ params: ListAdminProjectsParams,
174
+ ): Promise<ListAdminProjectsResult> {
175
+ const conditions: SQL[] = [];
176
+ const cursorCondition = cursorWhere(params.cursor);
177
+ if (cursorCondition) conditions.push(cursorCondition);
178
+ if (params.projectId) conditions.push(eq(projects.id, params.projectId));
179
+ if (params.search) {
180
+ conditions.push(ilike(projects.name, `%${escapeIlikePattern(params.search)}%`));
181
+ }
182
+
183
+ const rows = await db()
184
+ .select({
185
+ id: projects.id,
186
+ workspaceId: projects.workspaceId,
187
+ name: projects.name,
188
+ createdAt: projects.createdAt,
189
+ updatedAt: projects.updatedAt,
190
+ })
191
+ .from(projects)
192
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
193
+ .orderBy(desc(projects.createdAt), desc(projects.id))
194
+ .limit(params.limit + 1);
195
+
196
+ const hasMore = rows.length > params.limit;
197
+ const pageRows = hasMore ? rows.slice(0, params.limit) : rows;
198
+ const last = pageRows.at(-1);
199
+
200
+ return {
201
+ projects: pageRows,
202
+ nextCursor: hasMore && last ? {createdAt: last.createdAt, id: last.id} : null,
203
+ };
204
+ }
205
+
155
206
  export async function getProjectCount(): Promise<number> {
156
207
  const [row] = await db().select({value: count()}).from(projects);
157
208
  return row?.value ?? 0;
158
209
  }
210
+
211
+ export async function getWorkspaceProjectCounts(params: {
212
+ workspaceIds: string[];
213
+ }): Promise<Array<{workspaceId: string; count: number}>> {
214
+ const rows = await db()
215
+ .select({workspaceId: projects.workspaceId, count: count()})
216
+ .from(projects)
217
+ .where(inArray(projects.workspaceId, params.workspaceIds))
218
+ .groupBy(projects.workspaceId);
219
+
220
+ const countsByWorkspace = new Map(rows.map((row) => [row.workspaceId, Number(row.count)]));
221
+ return params.workspaceIds.map((workspaceId) => ({
222
+ workspaceId,
223
+ count: countsByWorkspace.get(workspaceId) ?? 0,
224
+ }));
225
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {dirname, resolve} from 'node:path';
2
2
  import {fileURLToPath} from 'node:url';
3
+ import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
3
4
  import {
4
5
  INTEGRATION_SOURCE_COMMIT_PUSHED,
5
6
  type IntegrationsEventMap,
@@ -34,6 +35,7 @@ export {
34
35
  db,
35
36
  getProjectById,
36
37
  getProjectBySource,
38
+ getWorkspaceProjectCounts,
37
39
  listProjects,
38
40
  migrationsPath,
39
41
  projectsOutbox,
@@ -43,13 +45,17 @@ export {createProjectRoutes, requireProjectAccess} from '#presentation/index.js'
43
45
 
44
46
  export interface CreateProjectsModuleOptions {
45
47
  integrations: IntegrationsModuleClient;
48
+ auth: AuthInterModuleClient;
46
49
  }
47
50
 
48
- export function createProjectsModule({integrations}: CreateProjectsModuleOptions): ShipfoxModule {
51
+ export function createProjectsModule({
52
+ integrations,
53
+ auth,
54
+ }: CreateProjectsModuleOptions): ShipfoxModule {
49
55
  return {
50
56
  name: 'projects',
51
57
  database: {db, migrationsPath, databaseNamespace: 'projects'},
52
- routes: createProjectRoutes(integrations),
58
+ routes: createProjectRoutes(integrations, auth),
53
59
  e2eRoutes: [projectsE2eRoutes],
54
60
  metrics: registerProjectsServiceMetrics,
55
61
  publishers: [{name: 'projects', table: projectsOutbox, db, eventSchemas: projectsEventSchemas}],
@@ -1 +1 @@
1
- export {toProjectDto} from './project.js';
1
+ export {toAdminProjectSummaryDto, toProjectDto} from './project.js';
@@ -1,4 +1,5 @@
1
1
  import type {Project} from '#core/entities/index.js';
2
+ import type {AdminProjectSummary} from '#db/projects.js';
2
3
 
3
4
  export function toProjectDto(project: Project) {
4
5
  return {
@@ -13,3 +14,14 @@ export function toProjectDto(project: Project) {
13
14
  updated_at: project.updatedAt.toISOString(),
14
15
  };
15
16
  }
17
+
18
+ export function toAdminProjectSummaryDto(project: AdminProjectSummary) {
19
+ return {
20
+ id: project.id,
21
+ name: project.name,
22
+ status: 'active' as const,
23
+ workspace_id: project.workspaceId,
24
+ created_at: project.createdAt.toISOString(),
25
+ updated_at: project.updatedAt.toISOString(),
26
+ };
27
+ }
@@ -4,7 +4,7 @@ import {
4
4
  defineInterModulePresentation,
5
5
  type InterModulePresentation,
6
6
  } from '@shipfox/inter-module';
7
- import {getProjectById} from '#db/projects.js';
7
+ import {getProjectById, getWorkspaceProjectCounts} from '#db/projects.js';
8
8
 
9
9
  export function createProjectsInterModulePresentation(): InterModulePresentation<
10
10
  typeof projectsInterModuleContract
@@ -29,5 +29,8 @@ export function createProjectsInterModulePresentation(): InterModulePresentation
29
29
  }
30
30
  return {project};
31
31
  },
32
+ getWorkspaceProjectCounts: async ({workspaceIds}) => ({
33
+ counts: await getWorkspaceProjectCounts({workspaceIds}),
34
+ }),
32
35
  });
33
36
  }
@@ -0,0 +1,66 @@
1
+ import {AUTH_USER, requireUserContext} from '@shipfox/api-auth-context';
2
+ import {
3
+ type AuthInterModuleClient,
4
+ authInterModuleContract,
5
+ } from '@shipfox/api-auth-dto/inter-module';
6
+ import {
7
+ listAdminProjectsQuerySchema,
8
+ listAdminProjectsResponseSchema,
9
+ } from '@shipfox/api-projects-dto';
10
+ import {isInterModuleKnownError} from '@shipfox/inter-module';
11
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
12
+ import {listAdminProjects} from '#db/index.js';
13
+ import {toAdminProjectSummaryDto} from '#presentation/dto/index.js';
14
+ import {decodeProjectCursor, encodeProjectCursor} from './cursor.js';
15
+
16
+ const minimumRole = 'admin-observer' as const;
17
+
18
+ function translateAdminProjectRouteError(error: unknown): never {
19
+ if (
20
+ isInterModuleKnownError(authInterModuleContract.methods.requireAdminRole, error) &&
21
+ error.code === 'admin-role-required'
22
+ ) {
23
+ throw new ClientError('Administrator observer role required', 'forbidden', {
24
+ status: 403,
25
+ details: {required_role: error.details.requiredRole},
26
+ });
27
+ }
28
+
29
+ throw error;
30
+ }
31
+
32
+ export function createAdminProjectsRoute(auth: Pick<AuthInterModuleClient, 'requireAdminRole'>) {
33
+ return defineRoute({
34
+ method: 'GET',
35
+ path: '/',
36
+ auth: AUTH_USER,
37
+ description: 'List a bounded safe project summary for administrators.',
38
+ schema: {
39
+ querystring: listAdminProjectsQuerySchema,
40
+ response: {200: listAdminProjectsResponseSchema},
41
+ },
42
+ errorHandler: translateAdminProjectRouteError,
43
+ handler: async (request) => {
44
+ const actor = requireUserContext(request);
45
+ await auth.requireAdminRole({userId: actor.userId, minimumRole});
46
+
47
+ const {project_id: projectId, limit, cursor, search} = request.query;
48
+ const decodedCursor = decodeProjectCursor(cursor);
49
+ if (cursor && !decodedCursor) {
50
+ throw new ClientError('Invalid cursor', 'invalid-cursor', {status: 400});
51
+ }
52
+
53
+ const result = await listAdminProjects({
54
+ projectId,
55
+ limit,
56
+ cursor: decodedCursor,
57
+ search,
58
+ });
59
+
60
+ return {
61
+ projects: result.projects.map(toAdminProjectSummaryDto),
62
+ next_cursor: result.nextCursor ? encodeProjectCursor(result.nextCursor) : null,
63
+ };
64
+ },
65
+ });
66
+ }
@@ -1,14 +1,23 @@
1
+ import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
1
2
  import type {IntegrationsModuleClient} from '@shipfox/api-integration-core-dto/inter-module';
2
3
  import type {RouteGroup} from '@shipfox/node-fastify';
4
+ import {createAdminProjectsRoute} from './admin-projects.js';
3
5
  import {createProjectRoute} from './create-project.js';
4
6
  import {getProjectRoute} from './get-project.js';
5
7
  import {listProjectsRoute} from './list-projects.js';
6
8
 
7
- export function createProjectRoutes(integrations: IntegrationsModuleClient): RouteGroup[] {
9
+ export function createProjectRoutes(
10
+ integrations: IntegrationsModuleClient,
11
+ auth: Pick<AuthInterModuleClient, 'requireAdminRole'>,
12
+ ): RouteGroup[] {
8
13
  return [
9
14
  {
10
15
  prefix: '/projects',
11
16
  routes: [createProjectRoute(integrations), listProjectsRoute, getProjectRoute],
12
17
  },
18
+ {
19
+ prefix: '/admin/projects',
20
+ routes: [createAdminProjectsRoute(auth)],
21
+ },
13
22
  ];
14
23
  }
@@ -4,6 +4,10 @@ import {
4
4
  setUserContext,
5
5
  type UserContextMembership,
6
6
  } from '@shipfox/api-auth-context';
7
+ import {
8
+ type AuthInterModuleClient,
9
+ authInterModuleContract,
10
+ } from '@shipfox/api-auth-dto/inter-module';
7
11
  import {
8
12
  type IntegrationsModuleClient,
9
13
  integrationsInterModuleContract,
@@ -12,6 +16,8 @@ import {createInterModuleKnownError} from '@shipfox/inter-module';
12
16
  import type {AuthMethod} from '@shipfox/node-fastify';
13
17
  import {closeApp, createApp} from '@shipfox/node-fastify';
14
18
  import type {FastifyInstance, FastifyRequest} from 'fastify';
19
+ import type {Project} from '#core/entities/project.js';
20
+ import {createProject} from '#db/projects.js';
15
21
  import {createProjectRoutes} from './index.js';
16
22
 
17
23
  let authenticatedMemberships: UserContextMembership[] = [];
@@ -37,6 +43,7 @@ describe('project routes', () => {
37
43
  let workspaceId: string;
38
44
  let sourceConnectionId: string;
39
45
  let integrations: Pick<IntegrationsModuleClient, 'resolveSourceRepository'>;
46
+ let auth: Pick<AuthInterModuleClient, 'requireAdminRole'>;
40
47
 
41
48
  beforeEach(async () => {
42
49
  await closeApp();
@@ -65,9 +72,12 @@ describe('project routes', () => {
65
72
  };
66
73
  }),
67
74
  };
75
+ auth = {
76
+ requireAdminRole: vi.fn().mockResolvedValue({role: 'admin-observer'}),
77
+ };
68
78
  app = await createApp({
69
79
  auth: [fakeUserAuth],
70
- routes: createProjectRoutes(integrations as IntegrationsModuleClient),
80
+ routes: createProjectRoutes(integrations as IntegrationsModuleClient, auth),
71
81
  swagger: false,
72
82
  });
73
83
  await app.ready();
@@ -252,4 +262,166 @@ describe('project routes', () => {
252
262
  expect(res.statusCode).toBe(404);
253
263
  expect(res.json().code).toBe('source-connection-not-found');
254
264
  });
265
+
266
+ test('requires the administrator observer role for project lookup', async () => {
267
+ vi.mocked(auth.requireAdminRole).mockRejectedValueOnce(
268
+ createInterModuleKnownError(
269
+ authInterModuleContract.methods.requireAdminRole,
270
+ 'admin-role-required',
271
+ {requiredRole: 'admin-observer'},
272
+ ),
273
+ );
274
+
275
+ const res = await app.inject({
276
+ method: 'GET',
277
+ url: '/admin/projects',
278
+ headers: {authorization: 'Bearer user'},
279
+ });
280
+
281
+ expect(res.statusCode).toBe(403);
282
+ expect(res.json()).toMatchObject({
283
+ code: 'forbidden',
284
+ details: {required_role: 'admin-observer'},
285
+ });
286
+ expect(auth.requireAdminRole).toHaveBeenCalledWith({
287
+ userId: 'user-1',
288
+ minimumRole: 'admin-observer',
289
+ });
290
+ });
291
+
292
+ test('rejects unbounded lookup parameters and malformed cursors', async () => {
293
+ const oversizedLimit = await app.inject({
294
+ method: 'GET',
295
+ url: '/admin/projects?limit=101',
296
+ headers: {authorization: 'Bearer user'},
297
+ });
298
+ expect(oversizedLimit.statusCode).toBe(400);
299
+
300
+ const oversizedSearch = await app.inject({
301
+ method: 'GET',
302
+ url: `/admin/projects?search=${'x'.repeat(101)}`,
303
+ headers: {authorization: 'Bearer user'},
304
+ });
305
+ expect(oversizedSearch.statusCode).toBe(400);
306
+
307
+ const malformedCursor = await app.inject({
308
+ method: 'GET',
309
+ url: '/admin/projects?cursor=not-a-cursor',
310
+ headers: {authorization: 'Bearer user'},
311
+ });
312
+ expect(malformedCursor.statusCode).toBe(400);
313
+ expect(malformedCursor.json().code).toBe('invalid-cursor');
314
+ });
315
+
316
+ test('returns a bounded redacted summary with checked search and cursor pagination', async () => {
317
+ const projects: Project[] = [];
318
+ for (const name of ['Platform', 'Runner', 'Running', 'Notifier']) {
319
+ projects.push(
320
+ await createProject({
321
+ workspaceId,
322
+ name,
323
+ sourceConnectionId: crypto.randomUUID(),
324
+ sourceExternalRepositoryId: `gitea:${name.toLowerCase()}`,
325
+ }),
326
+ );
327
+ }
328
+ const running = projects.find((project) => project.name === 'Running');
329
+ if (!running) throw new Error('Running project fixture was not created');
330
+
331
+ const searchRes = await app.inject({
332
+ method: 'GET',
333
+ url: '/admin/projects?search=runn&limit=1',
334
+ headers: {authorization: 'Bearer user'},
335
+ });
336
+
337
+ expect(searchRes.statusCode).toBe(200);
338
+ expect(searchRes.json()).toMatchObject({
339
+ projects: [
340
+ {
341
+ id: running.id,
342
+ name: 'Running',
343
+ status: 'active',
344
+ workspace_id: workspaceId,
345
+ },
346
+ ],
347
+ next_cursor: expect.any(String),
348
+ });
349
+ expect(searchRes.json().projects[0]).not.toHaveProperty('source');
350
+ expect(searchRes.json().projects[0]).not.toHaveProperty('source_connection_id');
351
+ expect(searchRes.json().projects[0]).not.toHaveProperty('source_external_repository_id');
352
+
353
+ const firstPage = await app.inject({
354
+ method: 'GET',
355
+ url: '/admin/projects?limit=1',
356
+ headers: {authorization: 'Bearer user'},
357
+ });
358
+ expect(firstPage.statusCode).toBe(200);
359
+ expect(firstPage.json().projects).toHaveLength(1);
360
+ expect(firstPage.json().next_cursor).toEqual(expect.any(String));
361
+
362
+ const secondPage = await app.inject({
363
+ method: 'GET',
364
+ url: `/admin/projects?limit=1&cursor=${encodeURIComponent(firstPage.json().next_cursor)}`,
365
+ headers: {authorization: 'Bearer user'},
366
+ });
367
+ expect(secondPage.statusCode).toBe(200);
368
+ expect(secondPage.json().projects).toHaveLength(1);
369
+ expect(secondPage.json().projects[0].id).not.toBe(firstPage.json().projects[0].id);
370
+ });
371
+
372
+ test('lists summaries across workspaces', async () => {
373
+ const firstWorkspaceId = crypto.randomUUID();
374
+ const secondWorkspaceId = crypto.randomUUID();
375
+ const firstProject = await createProject({
376
+ workspaceId: firstWorkspaceId,
377
+ name: 'GlobalAdminLookupAlpha',
378
+ sourceConnectionId: crypto.randomUUID(),
379
+ sourceExternalRepositoryId: 'gitea:global-admin-lookup-alpha',
380
+ });
381
+ const secondProject = await createProject({
382
+ workspaceId: secondWorkspaceId,
383
+ name: 'GlobalAdminLookupBeta',
384
+ sourceConnectionId: crypto.randomUUID(),
385
+ sourceExternalRepositoryId: 'gitea:global-admin-lookup-beta',
386
+ });
387
+
388
+ const res = await app.inject({
389
+ method: 'GET',
390
+ url: '/admin/projects?search=globaladminlookup&limit=100',
391
+ headers: {authorization: 'Bearer user'},
392
+ });
393
+
394
+ expect(res.statusCode).toBe(200);
395
+ expect(res.json().projects).toEqual(
396
+ expect.arrayContaining([
397
+ expect.objectContaining({id: firstProject.id, workspace_id: firstWorkspaceId}),
398
+ expect.objectContaining({id: secondProject.id, workspace_id: secondWorkspaceId}),
399
+ ]),
400
+ );
401
+ });
402
+
403
+ test('supports exact project ID lookup without exposing provider references', async () => {
404
+ const project = await createProject({
405
+ workspaceId,
406
+ name: 'Platform',
407
+ sourceConnectionId: crypto.randomUUID(),
408
+ sourceExternalRepositoryId: 'gitea:platform',
409
+ });
410
+
411
+ const res = await app.inject({
412
+ method: 'GET',
413
+ url: `/admin/projects?project_id=${project.id}`,
414
+ headers: {authorization: 'Bearer user'},
415
+ });
416
+
417
+ expect(res.statusCode).toBe(200);
418
+ expect(res.json().projects).toHaveLength(1);
419
+ expect(res.json().projects[0]).toMatchObject({
420
+ id: project.id,
421
+ name: 'Platform',
422
+ status: 'active',
423
+ workspace_id: workspaceId,
424
+ });
425
+ expect(res.json().projects[0]).not.toHaveProperty('source');
426
+ });
255
427
  });
@@ -1,3 +1,4 @@
1
+ import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
1
2
  import {
2
3
  INTEGRATION_SOURCE_COMMIT_PUSHED,
3
4
  type IntegrationSourceCommitPushedEvent,
@@ -141,7 +142,10 @@ describe('onSourceCommitPushed', () => {
141
142
  // The source/event filter is the subscription itself, so this module should
142
143
  // only receive the typed source-control event.
143
144
  it('registers the projects module on INTEGRATION_SOURCE_COMMIT_PUSHED', () => {
144
- const module = createProjectsModule({integrations: {} as IntegrationsModuleClient});
145
+ const module = createProjectsModule({
146
+ integrations: {} as IntegrationsModuleClient,
147
+ auth: {} as AuthInterModuleClient,
148
+ });
145
149
 
146
150
  const events = module.subscribers?.map((subscriber) => subscriber.event);
147
151