@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +38 -0
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/projects.d.ts +18 -0
- package/dist/db/projects.d.ts.map +1 -1
- package/dist/db/projects.js +45 -5
- package/dist/db/projects.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/presentation/dto/index.d.ts +1 -1
- package/dist/presentation/dto/index.d.ts.map +1 -1
- package/dist/presentation/dto/index.js +1 -1
- package/dist/presentation/dto/index.js.map +1 -1
- package/dist/presentation/dto/project.d.ts +9 -0
- package/dist/presentation/dto/project.d.ts.map +1 -1
- package/dist/presentation/dto/project.js +10 -0
- package/dist/presentation/dto/project.js.map +1 -1
- package/dist/presentation/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +7 -2
- package/dist/presentation/inter-module.js.map +1 -1
- package/dist/presentation/routes/admin-projects.d.ts +3 -0
- package/dist/presentation/routes/admin-projects.d.ts.map +1 -0
- package/dist/presentation/routes/admin-projects.js +61 -0
- package/dist/presentation/routes/admin-projects.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +2 -1
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +8 -1
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +6 -5
- package/src/db/index.ts +5 -0
- package/src/db/projects.test.ts +9 -1
- package/src/db/projects.ts +73 -6
- package/src/index.ts +8 -2
- package/src/presentation/dto/index.ts +1 -1
- package/src/presentation/dto/project.ts +12 -0
- package/src/presentation/inter-module.ts +4 -1
- package/src/presentation/routes/admin-projects.ts +66 -0
- package/src/presentation/routes/index.ts +10 -1
- package/src/presentation/routes/projects.test.ts +173 -1
- package/src/presentation/subscribers/on-source-commit-pushed.test.ts +5 -1
- 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": "
|
|
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-
|
|
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": "
|
|
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.
|
|
30
|
-
"@shipfox/node-module": "1.0.
|
|
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';
|
package/src/db/projects.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/db/projects.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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,
|
|
40
|
-
and(eq(projects.createdAt,
|
|
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({
|
|
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(
|
|
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({
|
|
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
|
|