mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
refactor(API): De-Express projects endpoints and drop direct-repo reads (no-changelog) (#37123)
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.18.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (26.5.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Verify single-instance deps (npm-install) (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.18.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (26.5.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Verify single-instance deps (npm-install) (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
This commit is contained in:
parent
614dfa62f8
commit
662a64de83
|
|
@ -1,6 +1,6 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import { Brackets, DataSource, Repository } from '@n8n/typeorm';
|
||||
import { Brackets, DataSource, In, Not, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { Project } from '../entities';
|
||||
|
||||
|
|
@ -43,6 +43,10 @@ export class ProjectRepository extends Repository<Project> {
|
|||
});
|
||||
}
|
||||
|
||||
async findTeamProjectsExcluding(excludedProjectIds: string[]): Promise<Project[]> {
|
||||
return await this.findBy({ type: 'team', id: Not(In(excludedProjectIds)) });
|
||||
}
|
||||
|
||||
async getAccessibleProjectsByExactName(
|
||||
userId: string,
|
||||
name: string,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,19 @@
|
|||
import { ListProjectsQueryDto } from '@n8n/api-types';
|
||||
import type { AuthenticatedRequest, ProjectRepository } from '@n8n/db';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import type { Response } from 'express';
|
||||
import type { Mock } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { ProjectController } from '@/controllers/project.controller';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
||||
import type { ProjectService } from '@/services/project.service.ee';
|
||||
import type { UserManagementMailer } from '@/user-management/email';
|
||||
|
||||
describe('ProjectController', () => {
|
||||
const eventService = mock<EventService>();
|
||||
const projectsService = mock<ProjectService>();
|
||||
const projectRepository = mock<ProjectRepository>();
|
||||
const userManagementMailer = mock<UserManagementMailer>();
|
||||
const provisioningService = mock<ProvisioningService>();
|
||||
|
||||
const controller = new ProjectController(
|
||||
projectsService as unknown as ProjectService,
|
||||
projectRepository as unknown as ProjectRepository,
|
||||
eventService as unknown as EventService,
|
||||
userManagementMailer as unknown as UserManagementMailer,
|
||||
provisioningService as unknown as ProvisioningService,
|
||||
);
|
||||
|
||||
|
|
@ -48,7 +40,10 @@ describe('ProjectController', () => {
|
|||
{ id: 'p1', name: 'Project 1' },
|
||||
{ id: 'p2', name: 'Project 2' },
|
||||
];
|
||||
(projectsService.getAccessibleProjectsAndCount as Mock).mockResolvedValue([projects, 2]);
|
||||
(projectsService.getAccessibleProjectsAndCount as Mock).mockResolvedValue({
|
||||
projects,
|
||||
count: 2,
|
||||
});
|
||||
(projectsService.addUserScopes as Mock).mockResolvedValue(projects);
|
||||
|
||||
const res = makeRes();
|
||||
|
|
@ -63,7 +58,10 @@ describe('ProjectController', () => {
|
|||
|
||||
it('returns bare array when no pagination params given', async () => {
|
||||
const projects = [{ id: 'p1', name: 'Project 1' }];
|
||||
(projectsService.getAccessibleProjectsAndCount as Mock).mockResolvedValue([projects, 1]);
|
||||
(projectsService.getAccessibleProjectsAndCount as Mock).mockResolvedValue({
|
||||
projects,
|
||||
count: 1,
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
// Simulate DTO-parsed output: when no query params are provided,
|
||||
|
|
@ -90,7 +88,10 @@ describe('ProjectController', () => {
|
|||
role: 'global:member',
|
||||
scopes: ['user:list'],
|
||||
}));
|
||||
(projectsService.getShareableProjectsAndCount as Mock).mockResolvedValue([projects, 2]);
|
||||
(projectsService.getShareableProjectsAndCount as Mock).mockResolvedValue({
|
||||
projects,
|
||||
count: 2,
|
||||
});
|
||||
(projectsService.addUserScopes as Mock).mockResolvedValue(enriched);
|
||||
|
||||
const res = makeRes();
|
||||
|
|
@ -104,7 +105,10 @@ describe('ProjectController', () => {
|
|||
});
|
||||
|
||||
it('always returns the { count, data } envelope (no bare-array path)', async () => {
|
||||
(projectsService.getShareableProjectsAndCount as Mock).mockResolvedValue([[], 0]);
|
||||
(projectsService.getShareableProjectsAndCount as Mock).mockResolvedValue({
|
||||
projects: [],
|
||||
count: 0,
|
||||
});
|
||||
(projectsService.addUserScopes as Mock).mockResolvedValue([]);
|
||||
|
||||
const res = makeRes();
|
||||
|
|
@ -118,31 +122,7 @@ describe('ProjectController', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('emits team-project-updated with custom telemetry tag count on updateProject', async () => {
|
||||
const projectId = 'p1';
|
||||
const payload = {
|
||||
name: 'Updated Project',
|
||||
customTelemetryTags: [
|
||||
{ key: 'env', value: 'production' },
|
||||
{ key: 'team', value: 'engineering' },
|
||||
],
|
||||
};
|
||||
|
||||
const res = makeRes();
|
||||
|
||||
await controller.updateProject(req, res, payload as any, projectId);
|
||||
|
||||
expect(projectsService.updateProject).toHaveBeenCalledWith(projectId, payload);
|
||||
expect(projectsService.getProjectRelations).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
|
||||
userId: 'actor-user',
|
||||
role: 'global:owner',
|
||||
projectId,
|
||||
otelProjectCustomTagsCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits team-project-updated without custom telemetry tag count on updateProject without tags', async () => {
|
||||
it('delegates updateProject to the service with the acting user', async () => {
|
||||
const projectId = 'p1';
|
||||
const payload = { name: 'Updated Project' };
|
||||
|
||||
|
|
@ -150,16 +130,10 @@ describe('ProjectController', () => {
|
|||
|
||||
await controller.updateProject(req, res, payload as any, projectId);
|
||||
|
||||
expect(projectsService.updateProject).toHaveBeenCalledWith(projectId, payload);
|
||||
expect(projectsService.getProjectRelations).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
|
||||
userId: 'actor-user',
|
||||
role: 'global:owner',
|
||||
projectId,
|
||||
});
|
||||
expect(projectsService.updateProject).toHaveBeenCalledWith(req.user, projectId, payload);
|
||||
});
|
||||
|
||||
it('emits team-project-updated with full members list on addProjectUsers', async () => {
|
||||
it('delegates addProjectUsers to the service with the acting user', async () => {
|
||||
// Arrange
|
||||
const projectId = 'p1';
|
||||
const payload = { relations: [{ userId: 'u2', role: 'project:viewer' as const }] };
|
||||
|
|
@ -171,11 +145,6 @@ describe('ProjectController', () => {
|
|||
conflicts: [],
|
||||
});
|
||||
|
||||
(projectsService.getProjectRelations as Mock).mockResolvedValue([
|
||||
{ userId: 'u1', role: { slug: 'project:admin' } },
|
||||
{ userId: 'u2', role: { slug: 'project:viewer' } },
|
||||
]);
|
||||
|
||||
const res = makeRes();
|
||||
|
||||
// Act
|
||||
|
|
@ -183,35 +152,16 @@ describe('ProjectController', () => {
|
|||
|
||||
// Assert
|
||||
expect(projectsService.addUsersWithConflictSemantics).toHaveBeenCalledWith(
|
||||
req.user,
|
||||
projectId,
|
||||
payload.relations,
|
||||
);
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
|
||||
userId: 'actor-user',
|
||||
role: 'global:owner',
|
||||
members: [
|
||||
{ userId: 'u1', role: 'project:admin' },
|
||||
{ userId: 'u2', role: 'project:viewer' },
|
||||
],
|
||||
projectId,
|
||||
});
|
||||
|
||||
// Verify mailer called for new sharees
|
||||
expect(userManagementMailer.notifyProjectShared).toHaveBeenCalledWith({
|
||||
sharer: req.user,
|
||||
newSharees: payload.relations,
|
||||
project: { id: projectId, name: 'Project' },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits team-project-updated on changeProjectUserRole and returns 204', async () => {
|
||||
it('delegates changeProjectUserRole to the service and returns 204', async () => {
|
||||
// Arrange
|
||||
const projectId = 'p2';
|
||||
provisioningService.isProjectRoleManaged.mockResolvedValue(false);
|
||||
(projectsService.getProjectRelations as Mock).mockResolvedValue([
|
||||
{ userId: 'u1', role: { slug: 'project:admin' } },
|
||||
{ userId: 'u2', role: { slug: 'project:editor' } },
|
||||
]);
|
||||
|
||||
const res = makeRes();
|
||||
|
||||
|
|
@ -221,26 +171,19 @@ describe('ProjectController', () => {
|
|||
} as any);
|
||||
|
||||
// Assert
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
|
||||
userId: 'actor-user',
|
||||
role: 'global:owner',
|
||||
members: [
|
||||
{ userId: 'u1', role: 'project:admin' },
|
||||
{ userId: 'u2', role: 'project:editor' },
|
||||
],
|
||||
expect(projectsService.changeUserRoleInProject).toHaveBeenCalledWith(
|
||||
req.user,
|
||||
projectId,
|
||||
});
|
||||
'u2',
|
||||
'project:editor',
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(204);
|
||||
});
|
||||
|
||||
it('emits team-project-updated on deleteProjectUser and returns 204', async () => {
|
||||
it('delegates deleteProjectUser to the service and returns 204', async () => {
|
||||
// Arrange
|
||||
const projectId = 'p3';
|
||||
provisioningService.isProjectRoleManaged.mockResolvedValue(false);
|
||||
(projectsService.getProjectRelations as Mock).mockResolvedValue([
|
||||
{ userId: 'u1', role: { slug: 'project:admin' } },
|
||||
{ userId: 'u3', role: { slug: 'project:viewer' } },
|
||||
]);
|
||||
|
||||
const res = makeRes();
|
||||
|
||||
|
|
@ -248,16 +191,7 @@ describe('ProjectController', () => {
|
|||
await controller.deleteProjectUser(req, res, projectId, 'u2');
|
||||
|
||||
// Assert
|
||||
expect(projectsService.deleteUserFromProject).toHaveBeenCalledWith(projectId, 'u2');
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
|
||||
userId: 'actor-user',
|
||||
role: 'global:owner',
|
||||
members: [
|
||||
{ userId: 'u1', role: 'project:admin' },
|
||||
{ userId: 'u3', role: 'project:viewer' },
|
||||
],
|
||||
projectId,
|
||||
});
|
||||
expect(projectsService.deleteUserFromProject).toHaveBeenCalledWith(req.user, projectId, 'u2');
|
||||
expect(res.status).toHaveBeenCalledWith(204);
|
||||
});
|
||||
|
||||
|
|
@ -279,12 +213,6 @@ describe('ProjectController', () => {
|
|||
conflicts,
|
||||
});
|
||||
|
||||
(projectsService.getProjectRelations as Mock).mockResolvedValue([
|
||||
{ userId: 'u1', role: { slug: 'project:admin' } },
|
||||
{ userId: 'u4', role: { slug: 'project:viewer' } },
|
||||
{ userId: 'u5', role: { slug: 'project:viewer' } },
|
||||
]);
|
||||
|
||||
const res = makeRes();
|
||||
|
||||
// Act
|
||||
|
|
@ -295,25 +223,6 @@ describe('ProjectController', () => {
|
|||
// Assert: 201 with conflicts body
|
||||
expect(res.status).toHaveBeenCalledWith(201);
|
||||
expect(res.json).toHaveBeenCalledWith({ conflicts });
|
||||
|
||||
// Mailer is called for newly added sharees
|
||||
expect(userManagementMailer.notifyProjectShared).toHaveBeenCalledWith({
|
||||
sharer: req.user,
|
||||
newSharees: added,
|
||||
project: { id: projectId, name: 'Project' },
|
||||
});
|
||||
|
||||
// Telemetry event has full members list
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
|
||||
userId: 'actor-user',
|
||||
role: 'global:owner',
|
||||
members: [
|
||||
{ userId: 'u1', role: 'project:admin' },
|
||||
{ userId: 'u4', role: 'project:viewer' },
|
||||
{ userId: 'u5', role: 'project:viewer' },
|
||||
],
|
||||
projectId,
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed project roles', () => {
|
||||
|
|
@ -329,8 +238,6 @@ describe('ProjectController', () => {
|
|||
).rejects.toThrow('Project roles are managed automatically');
|
||||
|
||||
expect(projectsService.addUsersWithConflictSemantics).not.toHaveBeenCalled();
|
||||
expect(userManagementMailer.notifyProjectShared).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks changeProjectUserRole when project roles are managed', async () => {
|
||||
|
|
@ -345,7 +252,6 @@ describe('ProjectController', () => {
|
|||
).rejects.toThrow('Project roles are managed automatically');
|
||||
|
||||
expect(projectsService.changeUserRoleInProject).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks deleteProjectUser when project roles are managed', async () => {
|
||||
|
|
@ -358,7 +264,6 @@ describe('ProjectController', () => {
|
|||
);
|
||||
|
||||
expect(projectsService.deleteUserFromProject).not.toHaveBeenCalled();
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([true, false])('exposes rolesManaged=%s on getProject', async (managed) => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
ChangeUserRoleInProject,
|
||||
ListProjectsQueryDto,
|
||||
} from '@n8n/api-types';
|
||||
import { AuthenticatedRequest, ProjectRepository } from '@n8n/db';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
|
|
@ -20,15 +20,12 @@ import {
|
|||
Param,
|
||||
Query,
|
||||
} from '@n8n/decorators';
|
||||
import { combineScopes, getAuthPrincipalScopes, hasGlobalScope } from '@n8n/permissions';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { In, Not } from '@n8n/typeorm';
|
||||
import { combineScopes, getAuthPrincipalScopes } from '@n8n/permissions';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
||||
import type { ProjectRequest } from '@/requests';
|
||||
import {
|
||||
|
|
@ -36,15 +33,11 @@ import {
|
|||
TeamProjectOverQuotaError,
|
||||
UnlicensedProjectRoleError,
|
||||
} from '@/services/project.service.ee';
|
||||
import { UserManagementMailer } from '@/user-management/email';
|
||||
|
||||
@RestController('/projects')
|
||||
export class ProjectController {
|
||||
constructor(
|
||||
private readonly projectsService: ProjectService,
|
||||
private readonly projectRepository: ProjectRepository,
|
||||
private readonly eventService: EventService,
|
||||
private readonly userManagementMailer: UserManagementMailer,
|
||||
private readonly provisioningService: ProvisioningService,
|
||||
) {}
|
||||
|
||||
|
|
@ -54,7 +47,7 @@ export class ProjectController {
|
|||
res: Response,
|
||||
@Query payload: ListProjectsQueryDto,
|
||||
) {
|
||||
const [data, count] = await this.projectsService.getAccessibleProjectsAndCount(
|
||||
const { projects, count } = await this.projectsService.getAccessibleProjectsAndCount(
|
||||
req.user,
|
||||
payload,
|
||||
);
|
||||
|
|
@ -63,10 +56,10 @@ export class ProjectController {
|
|||
// with role and scopes enriched per project.
|
||||
// Otherwise return a bare array for backward compatibility with existing callers.
|
||||
if (payload.take !== undefined || payload.skip !== undefined) {
|
||||
const enriched = await this.projectsService.addUserScopes(req.user, data);
|
||||
const enriched = await this.projectsService.addUserScopes(req.user, projects);
|
||||
return res.json({ count, data: enriched });
|
||||
}
|
||||
return data;
|
||||
return projects;
|
||||
}
|
||||
|
||||
@Get('/count')
|
||||
|
|
@ -86,11 +79,11 @@ export class ProjectController {
|
|||
res: Response,
|
||||
@Query payload: ListProjectsQueryDto,
|
||||
) {
|
||||
const [data, count] = await this.projectsService.getShareableProjectsAndCount(
|
||||
const { projects, count } = await this.projectsService.getShareableProjectsAndCount(
|
||||
req.user,
|
||||
payload,
|
||||
);
|
||||
const enriched = await this.projectsService.addUserScopes(req.user, data);
|
||||
const enriched = await this.projectsService.addUserScopes(req.user, projects);
|
||||
return res.json({ count, data: enriched });
|
||||
}
|
||||
|
||||
|
|
@ -102,26 +95,10 @@ export class ProjectController {
|
|||
try {
|
||||
const project = await this.projectsService.createTeamProject(req.user, payload);
|
||||
|
||||
this.eventService.emit('team-project-created', {
|
||||
userId: req.user.id,
|
||||
role: req.user.role.slug,
|
||||
uiContext: payload.uiContext,
|
||||
});
|
||||
|
||||
const relation = await this.projectsService.getProjectRelationForUserAndProject(
|
||||
req.user.id,
|
||||
project.id,
|
||||
);
|
||||
|
||||
return {
|
||||
...project,
|
||||
role: 'project:admin',
|
||||
scopes: [
|
||||
...combineScopes({
|
||||
global: getAuthPrincipalScopes(req.user),
|
||||
project: relation?.role.scopes.map((scope) => scope.slug) ?? [],
|
||||
}),
|
||||
],
|
||||
scopes: await this.projectsService.getProjectScopesForUser(req.user, project.id),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof TeamProjectOverQuotaError) {
|
||||
|
|
@ -136,61 +113,7 @@ export class ProjectController {
|
|||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
): Promise<ProjectRequest.GetMyProjectsResponse> {
|
||||
const relations = await this.projectsService.getProjectRelationsForUser(req.user);
|
||||
const otherTeamProject = hasGlobalScope(req.user, 'project:read')
|
||||
? await this.projectRepository.findBy({
|
||||
type: 'team',
|
||||
id: Not(In(relations.map((pr) => pr.projectId))),
|
||||
})
|
||||
: [];
|
||||
|
||||
const results: ProjectRequest.GetMyProjectsResponse = [];
|
||||
|
||||
for (const pr of relations) {
|
||||
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
|
||||
this.projectRepository.create(pr.project),
|
||||
{ role: pr.role.slug, scopes: [] },
|
||||
);
|
||||
|
||||
if (result.scopes) {
|
||||
result.scopes.push(
|
||||
...combineScopes({
|
||||
global: getAuthPrincipalScopes(req.user),
|
||||
project: pr.role.scopes.map((scope) => scope.slug),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
for (const project of otherTeamProject) {
|
||||
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
|
||||
this.projectRepository.create(project),
|
||||
{
|
||||
// If the user has the global `project:read` scope then they may not
|
||||
// own this relationship in that case we use the global user role
|
||||
// instead of the relation role, which is for another user.
|
||||
role: req.user.role.slug,
|
||||
scopes: [],
|
||||
},
|
||||
);
|
||||
|
||||
if (result.scopes) {
|
||||
result.scopes.push(...combineScopes({ global: getAuthPrincipalScopes(req.user) }));
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Deduplicate and sort scopes
|
||||
for (const result of results) {
|
||||
if (result.scopes) {
|
||||
result.scopes = [...new Set(result.scopes)].sort();
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
return await this.projectsService.getMyProjects(req.user);
|
||||
}
|
||||
|
||||
@Get('/personal')
|
||||
|
|
@ -200,16 +123,7 @@ export class ProjectController {
|
|||
throw new NotFoundError('Could not find a personal project for this user');
|
||||
}
|
||||
|
||||
const relation = await this.projectsService.getProjectRelationForUserAndProject(
|
||||
req.user.id,
|
||||
project.id,
|
||||
);
|
||||
const scopes: Scope[] = [
|
||||
...combineScopes({
|
||||
global: getAuthPrincipalScopes(req.user),
|
||||
project: relation?.role.scopes.map((scope) => scope.slug) ?? [],
|
||||
}),
|
||||
];
|
||||
const scopes = await this.projectsService.getProjectScopesForUser(req.user, project.id);
|
||||
return {
|
||||
...project,
|
||||
scopes,
|
||||
|
|
@ -265,15 +179,7 @@ export class ProjectController {
|
|||
@Body payload: UpdateProjectDto,
|
||||
@Param('projectId') projectId: string,
|
||||
) {
|
||||
await this.projectsService.updateProject(projectId, payload);
|
||||
this.eventService.emit('team-project-updated', {
|
||||
userId: req.user.id,
|
||||
role: req.user.role.slug,
|
||||
projectId,
|
||||
...(payload.customTelemetryTags !== undefined
|
||||
? { otelProjectCustomTagsCount: payload.customTelemetryTags.length }
|
||||
: {}),
|
||||
});
|
||||
await this.projectsService.updateProject(req.user, projectId, payload);
|
||||
}
|
||||
|
||||
/** Throws when project roles are provisioned automatically, so manual membership changes are disallowed. */
|
||||
|
|
@ -295,24 +201,11 @@ export class ProjectController {
|
|||
) {
|
||||
await this.assertProjectRolesNotManaged();
|
||||
try {
|
||||
const { added, conflicts, project } =
|
||||
await this.projectsService.addUsersWithConflictSemantics(projectId, payload.relations);
|
||||
|
||||
if (added.length > 0) {
|
||||
await this.userManagementMailer.notifyProjectShared({
|
||||
sharer: req.user,
|
||||
newSharees: added,
|
||||
project: { id: project.id, name: project.name },
|
||||
});
|
||||
}
|
||||
|
||||
const relations = await this.projectsService.getProjectRelations(projectId);
|
||||
this.eventService.emit('team-project-updated', {
|
||||
userId: req.user.id,
|
||||
role: req.user.role.slug,
|
||||
members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })),
|
||||
const { added, conflicts } = await this.projectsService.addUsersWithConflictSemantics(
|
||||
req.user,
|
||||
projectId,
|
||||
});
|
||||
payload.relations,
|
||||
);
|
||||
|
||||
// Response semantics:
|
||||
// - If at least one user was added, return 201. When there are also conflicts, include them in the body.
|
||||
|
|
@ -342,14 +235,7 @@ export class ProjectController {
|
|||
await this.assertProjectRolesNotManaged();
|
||||
|
||||
try {
|
||||
await this.projectsService.changeUserRoleInProject(projectId, userId, body.role);
|
||||
const relations = await this.projectsService.getProjectRelations(projectId);
|
||||
this.eventService.emit('team-project-updated', {
|
||||
userId: req.user.id,
|
||||
role: req.user.role.slug,
|
||||
members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })),
|
||||
projectId,
|
||||
});
|
||||
await this.projectsService.changeUserRoleInProject(req.user, projectId, userId, body.role);
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
if (e instanceof UnlicensedProjectRoleError) {
|
||||
|
|
@ -368,14 +254,7 @@ export class ProjectController {
|
|||
@Param('userId') userId: string,
|
||||
) {
|
||||
await this.assertProjectRolesNotManaged();
|
||||
await this.projectsService.deleteUserFromProject(projectId, userId);
|
||||
const relations = await this.projectsService.getProjectRelations(projectId);
|
||||
this.eventService.emit('team-project-updated', {
|
||||
userId: req.user.id,
|
||||
role: req.user.role.slug,
|
||||
members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })),
|
||||
projectId,
|
||||
});
|
||||
await this.projectsService.deleteUserFromProject(req.user, projectId, userId);
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
|
|
@ -390,13 +269,5 @@ export class ProjectController {
|
|||
await this.projectsService.deleteProject(req.user, projectId, {
|
||||
migrateToProject: query.transferId,
|
||||
});
|
||||
|
||||
this.eventService.emit('team-project-deleted', {
|
||||
userId: req.user.id,
|
||||
role: req.user.role.slug,
|
||||
projectId,
|
||||
removalType: query.transferId !== undefined ? 'transfer' : 'delete',
|
||||
targetProjectId: query.transferId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ describe('ProjectImporter.apply — custom span attributes', () => {
|
|||
await importer.apply(user, [item]);
|
||||
|
||||
expect(projectService.updateProject).toHaveBeenCalledWith(
|
||||
user,
|
||||
'proj-1',
|
||||
expect.objectContaining({ customTelemetryTags: tags }),
|
||||
);
|
||||
|
|
@ -70,6 +71,7 @@ describe('ProjectImporter.apply — custom span attributes', () => {
|
|||
await importer.apply(user, [item]);
|
||||
|
||||
expect(projectService.updateProject).toHaveBeenCalledWith(
|
||||
user,
|
||||
'proj-1',
|
||||
expect.objectContaining({ customTelemetryTags: undefined }),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export class ProjectImporter {
|
|||
}
|
||||
|
||||
case 'update':
|
||||
await this.projectService.updateProject(item.sourceProjectId, {
|
||||
await this.projectService.updateProject(user, item.sourceProjectId, {
|
||||
name: item.name,
|
||||
icon: item.icon,
|
||||
description: item.description,
|
||||
|
|
|
|||
|
|
@ -6,18 +6,9 @@ import {
|
|||
UpdateProjectWithRelationsDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import pick from 'lodash/pick';
|
||||
|
||||
import { ProjectController } from '@/controllers/project.controller';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
||||
import type { PaginatedRequest } from '@/public-api/types';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
import type { PublicAPIEndpoint } from '../../shared/handler.types';
|
||||
import {
|
||||
apiKeyHasScopeWithGlobalScopeFallback,
|
||||
|
|
@ -26,6 +17,13 @@ import {
|
|||
} from '../../shared/middlewares/global.middleware';
|
||||
import { encodeNextCursor } from '../../shared/services/pagination.service';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
||||
import type { PaginatedRequest } from '@/public-api/types';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
type GetAll = PaginatedRequest;
|
||||
type GetProjectUsersRequest = AuthenticatedRequest<{ projectId: string }> & GetAll;
|
||||
|
||||
|
|
@ -63,9 +61,13 @@ const projectHandlers: ProjectHandlers = {
|
|||
throw new BadRequestError(payload.error.errors[0].message);
|
||||
}
|
||||
|
||||
const project = await Container.get(ProjectController).createProject(req, res, payload.data);
|
||||
const projectService = Container.get(ProjectService);
|
||||
|
||||
return res.status(201).json(project);
|
||||
const project = await projectService.createTeamProject(req.user, payload.data);
|
||||
|
||||
const scopes = await projectService.getProjectScopesForUser(req.user, project.id);
|
||||
|
||||
return res.status(201).json({ ...project, role: 'project:admin', scopes });
|
||||
},
|
||||
],
|
||||
updateProject: [
|
||||
|
|
@ -77,12 +79,8 @@ const projectHandlers: ProjectHandlers = {
|
|||
throw new BadRequestError(payload.error.errors[0].message);
|
||||
}
|
||||
|
||||
await Container.get(ProjectController).updateProject(
|
||||
req,
|
||||
res,
|
||||
payload.data,
|
||||
req.params.projectId,
|
||||
);
|
||||
const { projectId } = req.params;
|
||||
await Container.get(ProjectService).updateProject(req.user, projectId, payload.data);
|
||||
|
||||
return res.status(204).send();
|
||||
},
|
||||
|
|
@ -96,12 +94,11 @@ const projectHandlers: ProjectHandlers = {
|
|||
throw new BadRequestError(query.error.errors[0].message);
|
||||
}
|
||||
|
||||
await Container.get(ProjectController).deleteProject(
|
||||
req,
|
||||
res,
|
||||
query.data,
|
||||
req.params.projectId,
|
||||
);
|
||||
const { projectId } = req.params;
|
||||
const { transferId } = query.data;
|
||||
await Container.get(ProjectService).deleteProject(req.user, projectId, {
|
||||
migrateToProject: transferId,
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
},
|
||||
|
|
@ -113,9 +110,9 @@ const projectHandlers: ProjectHandlers = {
|
|||
async (req, res) => {
|
||||
const { offset = 0, limit = 100 } = req.query;
|
||||
|
||||
const [projects, count] = await Container.get(ProjectRepository).findAndCount({
|
||||
skip: offset,
|
||||
take: limit,
|
||||
const { projects, count } = await Container.get(ProjectService).getProjectsAndCount({
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
|
|
@ -145,12 +142,9 @@ const projectHandlers: ProjectHandlers = {
|
|||
throw new NotFoundError(`Could not find project with ID "${projectId}"`);
|
||||
}
|
||||
|
||||
const projectRelationRepository = Container.get(ProjectRelationRepository);
|
||||
const [relations, count] = await projectRelationRepository.findAndCount({
|
||||
where: { projectId },
|
||||
relations: { user: true, role: true },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
const { members, count } = await projectService.getProjectMembersAndCount(projectId, {
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
|
||||
const memberFields = [
|
||||
|
|
@ -161,7 +155,7 @@ const projectHandlers: ProjectHandlers = {
|
|||
'createdAt',
|
||||
'updatedAt',
|
||||
] as const;
|
||||
const data = relations.map((relation) => ({
|
||||
const data = members.map((relation) => ({
|
||||
...pick(relation.user, memberFields),
|
||||
role: relation.role?.slug ?? null,
|
||||
}));
|
||||
|
|
@ -188,6 +182,7 @@ const projectHandlers: ProjectHandlers = {
|
|||
}
|
||||
|
||||
await Container.get(ProjectService).addUsersToProject(
|
||||
req.user,
|
||||
req.params.projectId,
|
||||
payload.data.relations,
|
||||
);
|
||||
|
|
@ -208,7 +203,12 @@ const projectHandlers: ProjectHandlers = {
|
|||
|
||||
const { projectId, userId } = req.params;
|
||||
const { role } = payload.data;
|
||||
await Container.get(ProjectService).changeUserRoleInProject(projectId, userId, role);
|
||||
await Container.get(ProjectService).changeUserRoleInProject(
|
||||
req.user,
|
||||
projectId,
|
||||
userId,
|
||||
role,
|
||||
);
|
||||
|
||||
return res.status(204).send();
|
||||
},
|
||||
|
|
@ -221,7 +221,7 @@ const projectHandlers: ProjectHandlers = {
|
|||
|
||||
const { projectId, userId } = req.params;
|
||||
|
||||
await Container.get(ProjectService).deleteUserFromProject(projectId, userId);
|
||||
await Container.get(ProjectService).deleteUserFromProject(req.user, projectId, userId);
|
||||
|
||||
return res.status(204).send();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type SharedWorkflowRepository,
|
||||
type ProjectRelationRepository,
|
||||
type SharedCredentials,
|
||||
type User,
|
||||
ProjectRelation as ProjectRelationEntity,
|
||||
PROJECT_ADMIN_ROLE,
|
||||
PROJECT_VIEWER_ROLE,
|
||||
|
|
@ -16,16 +17,18 @@ import type { EntityManager } from '@n8n/typeorm';
|
|||
import type { Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { OwnershipService } from '../ownership.service';
|
||||
import { ProjectService } from '../project.service.ee';
|
||||
import type { RoleService } from '../role.service';
|
||||
|
||||
import type { ICredentialConnectionStatusProvider } from '@/credentials/credential-connection-status-provider.interface';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { AgentChatAttachmentService } from '@/modules/agents/agent-chat-attachment.service';
|
||||
import type { AgentExecutionService } from '@/modules/agents/agent-execution.service';
|
||||
import type { AgentKnowledgeService } from '@/modules/agents/agent-knowledge.service';
|
||||
import type { AgentRepository } from '@/modules/agents/repositories/agent.repository';
|
||||
|
||||
import type { OwnershipService } from '../ownership.service';
|
||||
import { ProjectService } from '../project.service.ee';
|
||||
import type { RoleService } from '../role.service';
|
||||
import type { UserManagementMailer } from '@/user-management/email';
|
||||
|
||||
describe('ProjectService', () => {
|
||||
const manager = mock<EntityManager>();
|
||||
|
|
@ -41,6 +44,9 @@ describe('ProjectService', () => {
|
|||
const agentChatAttachmentService = mock<AgentChatAttachmentService>();
|
||||
const ownershipService = mock<OwnershipService>();
|
||||
const logger = mock<Logger>();
|
||||
const eventService = mock<EventService>();
|
||||
const userManagementMailer = mock<UserManagementMailer>();
|
||||
const user = mock<User>({ id: 'actor-user', role: mock({ slug: 'global:owner' }) });
|
||||
const projectService = new ProjectService(
|
||||
sharedWorkflowRepository,
|
||||
projectRepository,
|
||||
|
|
@ -52,10 +58,13 @@ describe('ProjectService', () => {
|
|||
moduleRegistry,
|
||||
ownershipService,
|
||||
logger,
|
||||
eventService,
|
||||
userManagementMailer,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
projectRelationRepository.find.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('getAccessibleProjectsAndCount', () => {
|
||||
|
|
@ -73,7 +82,7 @@ describe('ProjectService', () => {
|
|||
const result = await projectService.getAccessibleProjectsAndCount(adminUser, options);
|
||||
|
||||
expect(projectRepository.findAllProjectsAndCount).toHaveBeenCalledWith(options);
|
||||
expect(result).toEqual(expected);
|
||||
expect(result).toEqual({ projects: expected[0], count: expected[1] });
|
||||
});
|
||||
|
||||
it('should call getAccessibleProjectsAndCount for non-admin users', async () => {
|
||||
|
|
@ -91,7 +100,7 @@ describe('ProjectService', () => {
|
|||
'member-user',
|
||||
options,
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
expect(result).toEqual({ projects: expected[0], count: expected[1] });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -106,7 +115,9 @@ describe('ProjectService', () => {
|
|||
|
||||
// ACT & ASSERT
|
||||
await expect(
|
||||
projectService.addUsersToProject(projectId, [{ userId: '1234', role: 'project:admin' }]),
|
||||
projectService.addUsersToProject(user, projectId, [
|
||||
{ userId: '1234', role: 'project:admin' },
|
||||
]),
|
||||
).rejects.toThrowError("Can't add users to personal projects.");
|
||||
});
|
||||
|
||||
|
|
@ -120,11 +131,62 @@ describe('ProjectService', () => {
|
|||
|
||||
// ACT & ASSERT
|
||||
await expect(
|
||||
projectService.addUsersToProject(projectId, [
|
||||
projectService.addUsersToProject(user, projectId, [
|
||||
{ userId: '1234', role: PROJECT_OWNER_ROLE_SLUG },
|
||||
]),
|
||||
).rejects.toThrowError("Can't add a personalOwner to a team project.");
|
||||
});
|
||||
|
||||
it('notifies only the newly added users, not existing members', async () => {
|
||||
// ARRANGE
|
||||
const projectId = '12345';
|
||||
projectRepository.findOne.mockResolvedValueOnce(
|
||||
mock<Project>({
|
||||
id: projectId,
|
||||
name: 'Team Project',
|
||||
type: 'team',
|
||||
// `existing` is already a member; `newcomer` is not.
|
||||
projectRelations: [mock<ProjectRelationEntity>({ userId: 'existing' })],
|
||||
}),
|
||||
);
|
||||
roleService.isRoleLicensed.mockReturnValue(true);
|
||||
|
||||
// ACT
|
||||
await projectService.addUsersToProject(user, projectId, [
|
||||
{ userId: 'existing', role: 'project:admin' },
|
||||
{ userId: 'newcomer', role: 'project:viewer' },
|
||||
]);
|
||||
|
||||
// ASSERT: mailer called once, only for the newcomer
|
||||
expect(userManagementMailer.notifyProjectShared).toHaveBeenCalledTimes(1);
|
||||
expect(userManagementMailer.notifyProjectShared).toHaveBeenCalledWith({
|
||||
sharer: user,
|
||||
newSharees: [{ userId: 'newcomer', role: 'project:viewer' }],
|
||||
project: { id: projectId, name: 'Team Project' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not notify when no users are new', async () => {
|
||||
// ARRANGE
|
||||
const projectId = '12345';
|
||||
projectRepository.findOne.mockResolvedValueOnce(
|
||||
mock<Project>({
|
||||
id: projectId,
|
||||
name: 'Team Project',
|
||||
type: 'team',
|
||||
projectRelations: [mock<ProjectRelationEntity>({ userId: 'existing' })],
|
||||
}),
|
||||
);
|
||||
roleService.isRoleLicensed.mockReturnValue(true);
|
||||
|
||||
// ACT
|
||||
await projectService.addUsersToProject(user, projectId, [
|
||||
{ userId: 'existing', role: 'project:admin' },
|
||||
]);
|
||||
|
||||
// ASSERT
|
||||
expect(userManagementMailer.notifyProjectShared).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProjectRelations', () => {
|
||||
|
|
@ -337,7 +399,7 @@ describe('ProjectService', () => {
|
|||
);
|
||||
|
||||
// ACT
|
||||
await projectService.deleteUserFromProject(projectId, userId);
|
||||
await projectService.deleteUserFromProject(user, projectId, userId);
|
||||
|
||||
// ASSERT — member removed → cleanup must run inside the same transaction
|
||||
expect(mockProxy.cleanupOrphanedEntriesForUsers).toHaveBeenCalledWith([userId], manager);
|
||||
|
|
@ -358,7 +420,7 @@ describe('ProjectService', () => {
|
|||
);
|
||||
|
||||
// ACT & ASSERT
|
||||
await expect(projectService.deleteUserFromProject(projectId, ownerId)).rejects.toThrow(
|
||||
await expect(projectService.deleteUserFromProject(user, projectId, ownerId)).rejects.toThrow(
|
||||
'Project owner cannot be removed from the project',
|
||||
);
|
||||
expect(mockProxy.cleanupOrphanedEntriesForUsers).not.toHaveBeenCalled();
|
||||
|
|
@ -374,7 +436,7 @@ describe('ProjectService', () => {
|
|||
it('should trim whitespace from tag keys on save', async () => {
|
||||
projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never);
|
||||
|
||||
await projectService.updateProject('proj-1', {
|
||||
await projectService.updateProject(user, 'proj-1', {
|
||||
name: 'My Project',
|
||||
customTelemetryTags: [
|
||||
{ key: ' env ', value: 'production' },
|
||||
|
|
@ -396,7 +458,7 @@ describe('ProjectService', () => {
|
|||
it('should filter out tags with empty keys after trimming', async () => {
|
||||
projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never);
|
||||
|
||||
await projectService.updateProject('proj-1', {
|
||||
await projectService.updateProject(user, 'proj-1', {
|
||||
name: 'My Project',
|
||||
customTelemetryTags: [
|
||||
{ key: ' ', value: 'ignored' },
|
||||
|
|
@ -415,7 +477,7 @@ describe('ProjectService', () => {
|
|||
it('should save undefined customTelemetryTags when not provided', async () => {
|
||||
projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never);
|
||||
|
||||
await projectService.updateProject('proj-1', { name: 'My Project' });
|
||||
await projectService.updateProject(user, 'proj-1', { name: 'My Project' });
|
||||
|
||||
expect(projectRepository.update).toHaveBeenCalledWith(
|
||||
{ id: 'proj-1', type: 'team' },
|
||||
|
|
@ -426,7 +488,7 @@ describe('ProjectService', () => {
|
|||
it('should invalidate workflow project cache after a successful update', async () => {
|
||||
projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never);
|
||||
|
||||
await projectService.updateProject('proj-1', { name: 'Updated' });
|
||||
await projectService.updateProject(user, 'proj-1', { name: 'Updated' });
|
||||
|
||||
expect(ownershipService.invalidateWorkflowProjectCacheForProject).toHaveBeenCalledWith(
|
||||
'proj-1',
|
||||
|
|
@ -436,9 +498,50 @@ describe('ProjectService', () => {
|
|||
it('should throw NotFoundError when project is not found', async () => {
|
||||
projectRepository.update.mockResolvedValueOnce({ affected: 0 } as never);
|
||||
|
||||
await expect(projectService.updateProject('missing-proj', { name: 'Ghost' })).rejects.toThrow(
|
||||
'Could not find project with ID: missing-proj',
|
||||
);
|
||||
await expect(
|
||||
projectService.updateProject(user, 'missing-proj', { name: 'Ghost' }),
|
||||
).rejects.toThrow('Could not find project with ID: missing-proj');
|
||||
});
|
||||
|
||||
it('emits team-project-updated with the otel tag count when tags are provided', async () => {
|
||||
projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never);
|
||||
|
||||
await projectService.updateProject(user, 'proj-1', {
|
||||
name: 'My Project',
|
||||
customTelemetryTags: [
|
||||
{ key: 'env', value: 'production' },
|
||||
{ key: 'team', value: 'backend' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
|
||||
userId: 'actor-user',
|
||||
role: 'global:owner',
|
||||
projectId: 'proj-1',
|
||||
otelProjectCustomTagsCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits team-project-updated without the otel tag count when tags are omitted', async () => {
|
||||
projectRepository.update.mockResolvedValueOnce({ affected: 1 } as never);
|
||||
|
||||
await projectService.updateProject(user, 'proj-1', { name: 'My Project' });
|
||||
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
|
||||
userId: 'actor-user',
|
||||
role: 'global:owner',
|
||||
projectId: 'proj-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not emit when the project is not found', async () => {
|
||||
projectRepository.update.mockResolvedValueOnce({ affected: 0 } as never);
|
||||
|
||||
await expect(
|
||||
projectService.updateProject(user, 'missing-proj', { name: 'Ghost' }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -474,8 +577,9 @@ describe('ProjectService', () => {
|
|||
}),
|
||||
);
|
||||
roleService.isRoleLicensed.mockReturnValue(true);
|
||||
projectRelationRepository.find.mockResolvedValue(mockRelations as never);
|
||||
|
||||
await projectService.changeUserRoleInProject(projectId, 'user2', 'project:admin');
|
||||
await projectService.changeUserRoleInProject(user, projectId, 'user2', 'project:admin');
|
||||
|
||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: projectId, type: 'team' },
|
||||
|
|
@ -488,6 +592,16 @@ describe('ProjectService', () => {
|
|||
{ role: { slug: 'project:admin' } },
|
||||
);
|
||||
expect(mockProxy.cleanupOrphanedEntriesForUsers).toHaveBeenCalledWith(['user2'], manager);
|
||||
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
|
||||
userId: 'actor-user',
|
||||
role: 'global:owner',
|
||||
members: [
|
||||
{ userId: 'user1', role: 'project:admin' },
|
||||
{ userId: 'user2', role: 'project:viewer' },
|
||||
],
|
||||
projectId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw if the user is not part of the project', async () => {
|
||||
|
|
@ -501,7 +615,7 @@ describe('ProjectService', () => {
|
|||
roleService.isRoleLicensed.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
projectService.changeUserRoleInProject(projectId, 'user3', 'project:admin'),
|
||||
projectService.changeUserRoleInProject(user, projectId, 'user3', 'project:admin'),
|
||||
).rejects.toThrow(`Could not find project with ID: ${projectId}`);
|
||||
|
||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||
|
|
@ -512,7 +626,7 @@ describe('ProjectService', () => {
|
|||
|
||||
it('should throw if the role to be set is `project:personalOwner`', async () => {
|
||||
await expect(
|
||||
projectService.changeUserRoleInProject(projectId, 'user2', PROJECT_OWNER_ROLE_SLUG),
|
||||
projectService.changeUserRoleInProject(user, projectId, 'user2', PROJECT_OWNER_ROLE_SLUG),
|
||||
).rejects.toThrow('Personal owner cannot be added to a team project.');
|
||||
});
|
||||
|
||||
|
|
@ -521,7 +635,7 @@ describe('ProjectService', () => {
|
|||
roleService.isRoleLicensed.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
projectService.changeUserRoleInProject(projectId, 'user2', 'project:admin'),
|
||||
projectService.changeUserRoleInProject(user, projectId, 'user2', 'project:admin'),
|
||||
).rejects.toThrow(`Could not find project with ID: ${projectId}`);
|
||||
|
||||
expect(projectRepository.findOne).toHaveBeenCalledWith({
|
||||
|
|
@ -532,7 +646,10 @@ describe('ProjectService', () => {
|
|||
});
|
||||
|
||||
describe('deleteProject', () => {
|
||||
const user = { id: 'user-1', role: { scopes: [{ slug: 'project:delete' }] } } as any;
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
role: { slug: 'global:owner', scopes: [{ slug: 'project:delete' }] },
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(projectService, 'workflowService', {
|
||||
|
|
@ -576,6 +693,14 @@ describe('ProjectService', () => {
|
|||
expect(projectRepository.remove.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockProxy.cleanupOrphanedEntriesForUsers.mock.invocationCallOrder[0],
|
||||
);
|
||||
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-deleted', {
|
||||
userId: 'user-1',
|
||||
role: 'global:owner',
|
||||
projectId: project.id,
|
||||
removalType: 'delete',
|
||||
targetProjectId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips credential cleanup when the project had no members', async () => {
|
||||
|
|
@ -666,6 +791,7 @@ describe('ProjectService', () => {
|
|||
const migratingUser = {
|
||||
id: 'user-1',
|
||||
role: {
|
||||
slug: 'global:owner',
|
||||
scopes: [
|
||||
{ slug: 'project:delete' },
|
||||
{ slug: 'credential:create' },
|
||||
|
|
@ -739,6 +865,14 @@ describe('ProjectService', () => {
|
|||
'team-2',
|
||||
);
|
||||
expect(projectRepository.remove).toHaveBeenCalledWith(project);
|
||||
|
||||
expect(eventService.emit).toHaveBeenCalledWith('team-project-deleted', {
|
||||
userId: 'user-1',
|
||||
role: 'global:owner',
|
||||
projectId: project.id,
|
||||
removalType: 'transfer',
|
||||
targetProjectId: 'team-2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
getAuthPrincipalScopes,
|
||||
hasGlobalScope,
|
||||
type Scope,
|
||||
type GlobalRole,
|
||||
type ProjectRole,
|
||||
AssignableProjectRole,
|
||||
PROJECT_OWNER_ROLE_SLUG,
|
||||
PROJECT_ADMIN_ROLE_SLUG,
|
||||
|
|
@ -27,12 +29,14 @@ import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm';
|
|||
import { In } from '@n8n/typeorm';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { OwnershipService } from './ownership.service';
|
||||
import { RoleService } from './role.service';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { OwnershipService } from './ownership.service';
|
||||
import { RoleService } from './role.service';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { UserManagementMailer } from '@/user-management/email';
|
||||
|
||||
export class TeamProjectOverQuotaError extends UserError {
|
||||
constructor(limit: number) {
|
||||
|
|
@ -69,6 +73,11 @@ export interface ProjectCreateOverrides {
|
|||
customTelemetryTags?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
export type ProjectWithRelationsAndScopes = Project & {
|
||||
role: ProjectRole | AssignableProjectRole | GlobalRole;
|
||||
scopes?: Scope[];
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class ProjectService {
|
||||
constructor(
|
||||
|
|
@ -82,6 +91,8 @@ export class ProjectService {
|
|||
private readonly moduleRegistry: ModuleRegistry,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
private readonly logger: Logger,
|
||||
private readonly eventService: EventService,
|
||||
private readonly userManagementMailer: UserManagementMailer,
|
||||
) {}
|
||||
|
||||
private get workflowService() {
|
||||
|
|
@ -305,6 +316,14 @@ export class ProjectService {
|
|||
const proxy = await this.connectionStatusProxy;
|
||||
await proxy.cleanupOrphanedEntriesForUsers(memberUserIds);
|
||||
}
|
||||
|
||||
this.eventService.emit('team-project-deleted', {
|
||||
userId: user.id,
|
||||
role: user.role.slug,
|
||||
projectId,
|
||||
removalType: migrateToProject !== undefined ? 'transfer' : 'delete',
|
||||
targetProjectId: migrateToProject,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -364,11 +383,11 @@ export class ProjectService {
|
|||
async getAccessibleProjectsAndCount(
|
||||
user: User,
|
||||
options: ProjectListOptions,
|
||||
): Promise<[Project[], number]> {
|
||||
if (hasGlobalScope(user, 'project:read')) {
|
||||
return await this.projectRepository.findAllProjectsAndCount(options);
|
||||
}
|
||||
return await this.projectRepository.getAccessibleProjectsAndCount(user.id, options);
|
||||
): Promise<{ projects: Project[]; count: number }> {
|
||||
const [projects, count] = hasGlobalScope(user, 'project:read')
|
||||
? await this.projectRepository.findAllProjectsAndCount(options)
|
||||
: await this.projectRepository.getAccessibleProjectsAndCount(user.id, options);
|
||||
return { projects, count };
|
||||
}
|
||||
|
||||
// Returns the projects a caller can pick as share targets, including peer
|
||||
|
|
@ -378,17 +397,88 @@ export class ProjectService {
|
|||
async getShareableProjectsAndCount(
|
||||
user: User,
|
||||
options: ProjectListOptions,
|
||||
): Promise<[Project[], number]> {
|
||||
if (hasGlobalScope(user, 'project:read')) {
|
||||
return await this.projectRepository.findAllProjectsAndCount(options);
|
||||
}
|
||||
return await this.projectRepository.getShareableProjectsAndCount(user.id, options);
|
||||
): Promise<{ projects: Project[]; count: number }> {
|
||||
const [projects, count] = hasGlobalScope(user, 'project:read')
|
||||
? await this.projectRepository.findAllProjectsAndCount(options)
|
||||
: await this.projectRepository.getShareableProjectsAndCount(user.id, options);
|
||||
return { projects, count };
|
||||
}
|
||||
|
||||
async getProjectsAndCount({
|
||||
offset,
|
||||
limit,
|
||||
}: {
|
||||
offset: number;
|
||||
limit: number;
|
||||
}): Promise<{ projects: Project[]; count: number }> {
|
||||
const [projects, count] = await this.projectRepository.findAndCount({
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
return { projects, count };
|
||||
}
|
||||
|
||||
async getPersonalProjectOwners(projectIds: string[]): Promise<ProjectRelation[]> {
|
||||
return await this.projectRelationRepository.getPersonalProjectOwners(projectIds);
|
||||
}
|
||||
|
||||
async getMyProjects(user: User): Promise<ProjectWithRelationsAndScopes[]> {
|
||||
const projectRelations = await this.getProjectRelationsForUser(user);
|
||||
const otherTeamProjects = hasGlobalScope(user, 'project:read')
|
||||
? await this.projectRepository.findTeamProjectsExcluding(
|
||||
projectRelations.map((relation) => relation.projectId),
|
||||
)
|
||||
: [];
|
||||
|
||||
const results: ProjectWithRelationsAndScopes[] = [];
|
||||
|
||||
for (const relation of projectRelations) {
|
||||
const result: ProjectWithRelationsAndScopes = Object.assign(
|
||||
this.projectRepository.create(relation.project),
|
||||
{ role: relation.role.slug, scopes: [] },
|
||||
);
|
||||
|
||||
if (result.scopes) {
|
||||
result.scopes.push(
|
||||
...combineScopes({
|
||||
global: getAuthPrincipalScopes(user),
|
||||
project: relation.role.scopes.map((scope) => scope.slug),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
for (const project of otherTeamProjects) {
|
||||
const result: ProjectWithRelationsAndScopes = Object.assign(
|
||||
this.projectRepository.create(project),
|
||||
{
|
||||
// If the user has the global `project:read` scope then they may not
|
||||
// own this relationship in that case we use the global user role
|
||||
// instead of the relation role, which is for another user.
|
||||
role: user.role.slug,
|
||||
scopes: [],
|
||||
},
|
||||
);
|
||||
|
||||
if (result.scopes) {
|
||||
result.scopes.push(...combineScopes({ global: getAuthPrincipalScopes(user) }));
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Deduplicate and sort scopes
|
||||
for (const result of results) {
|
||||
if (result.scopes) {
|
||||
result.scopes = [...new Set(result.scopes)].sort();
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async createTeamProjectWithEntityManager(
|
||||
adminUser: User,
|
||||
data: CreateProjectDto,
|
||||
|
|
@ -426,12 +516,24 @@ export class ProjectService {
|
|||
): Promise<Project> {
|
||||
// This needs to be SERIALIZABLE otherwise the count would not block a
|
||||
// concurrent transaction and we could insert multiple projects.
|
||||
return await this.projectRepository.manager.transaction('SERIALIZABLE', async (trx) => {
|
||||
return await this.createTeamProjectWithEntityManager(adminUser, data, trx, overrides);
|
||||
const project = await this.projectRepository.manager.transaction(
|
||||
'SERIALIZABLE',
|
||||
async (trx) => {
|
||||
return await this.createTeamProjectWithEntityManager(adminUser, data, trx, overrides);
|
||||
},
|
||||
);
|
||||
|
||||
this.eventService.emit('team-project-created', {
|
||||
userId: adminUser.id,
|
||||
role: adminUser.role.slug,
|
||||
uiContext: data.uiContext,
|
||||
});
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
async updateProject(
|
||||
user: User,
|
||||
projectId: string,
|
||||
{ name, icon, description, customTelemetryTags }: UpdateProjectDto,
|
||||
): Promise<void> {
|
||||
|
|
@ -449,6 +551,15 @@ export class ProjectService {
|
|||
|
||||
// Ensure OTel spans pick up the updated customTelemetryTags on the next execution.
|
||||
await this.ownershipService.invalidateWorkflowProjectCacheForProject(projectId);
|
||||
|
||||
this.eventService.emit('team-project-updated', {
|
||||
userId: user.id,
|
||||
role: user.role.slug,
|
||||
projectId,
|
||||
...(customTelemetryTags !== undefined
|
||||
? { otelProjectCustomTagsCount: customTelemetryTags.length }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
async getPersonalProject(user: User): Promise<Project | null> {
|
||||
|
|
@ -513,6 +624,29 @@ export class ProjectService {
|
|||
return { project, newRelations };
|
||||
}
|
||||
|
||||
private async notifyNewSharees(
|
||||
sharer: User,
|
||||
project: Project,
|
||||
newSharees: Array<{ userId: string; role: AssignableProjectRole }>,
|
||||
) {
|
||||
if (newSharees.length === 0) return;
|
||||
await this.userManagementMailer.notifyProjectShared({
|
||||
sharer,
|
||||
newSharees,
|
||||
project: { id: project.id, name: project.name },
|
||||
});
|
||||
}
|
||||
|
||||
private async emitProjectMembersUpdated(user: User, projectId: string) {
|
||||
const relations = await this.getProjectRelations(projectId);
|
||||
this.eventService.emit('team-project-updated', {
|
||||
userId: user.id,
|
||||
role: user.role.slug,
|
||||
members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })),
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds users to a team project with specified roles.
|
||||
*
|
||||
|
|
@ -520,6 +654,7 @@ export class ProjectService {
|
|||
* Throws if the relations contain `project:personalOwner`.
|
||||
*/
|
||||
async addUsersToProject(
|
||||
user: User,
|
||||
projectId: string,
|
||||
relations: Array<{ userId: string; role: AssignableProjectRole }>,
|
||||
) {
|
||||
|
|
@ -540,6 +675,9 @@ export class ProjectService {
|
|||
throw new ForbiddenError("Can't add a personalOwner to a team project.");
|
||||
}
|
||||
|
||||
const existingUserIds = new Set(project.projectRelations.map((pr) => pr.userId));
|
||||
const newSharees = relations.filter((relation) => !existingUserIds.has(relation.userId));
|
||||
|
||||
await this.projectRelationRepository.save(
|
||||
relations.map((relation) => ({
|
||||
projectId,
|
||||
|
|
@ -547,6 +685,9 @@ export class ProjectService {
|
|||
role: { slug: relation.role },
|
||||
})),
|
||||
);
|
||||
|
||||
await this.notifyNewSharees(user, project, newSharees);
|
||||
await this.emitProjectMembersUpdated(user, projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -556,6 +697,7 @@ export class ProjectService {
|
|||
* - Reports conflicts for users already in the project with a different role (no change)
|
||||
*/
|
||||
async addUsersWithConflictSemantics(
|
||||
user: User,
|
||||
projectId: string,
|
||||
relations: Array<{ userId: string; role: AssignableProjectRole }>,
|
||||
): Promise<{
|
||||
|
|
@ -607,6 +749,9 @@ export class ProjectService {
|
|||
added.push(...toInsert);
|
||||
}
|
||||
|
||||
await this.notifyNewSharees(user, project, added);
|
||||
await this.emitProjectMembersUpdated(user, project.id);
|
||||
|
||||
return { project, added, conflicts };
|
||||
}
|
||||
|
||||
|
|
@ -640,7 +785,7 @@ export class ProjectService {
|
|||
);
|
||||
}
|
||||
|
||||
async deleteUserFromProject(projectId: string, userId: string) {
|
||||
async deleteUserFromProject(user: User, projectId: string, userId: string) {
|
||||
const project = await this.getTeamProjectWithRelations(projectId);
|
||||
|
||||
// Prevent project owner from being removed
|
||||
|
|
@ -654,9 +799,16 @@ export class ProjectService {
|
|||
await em.delete(ProjectRelation, { projectId: project.id, userId });
|
||||
await proxy.cleanupOrphanedEntriesForUsers([userId], em);
|
||||
});
|
||||
|
||||
await this.emitProjectMembersUpdated(user, projectId);
|
||||
}
|
||||
|
||||
async changeUserRoleInProject(projectId: string, userId: string, role: AssignableProjectRole) {
|
||||
async changeUserRoleInProject(
|
||||
user: User,
|
||||
projectId: string,
|
||||
userId: string,
|
||||
role: AssignableProjectRole,
|
||||
) {
|
||||
if (role === PROJECT_OWNER_ROLE_SLUG) {
|
||||
throw new ForbiddenError('Personal owner cannot be added to a team project.');
|
||||
}
|
||||
|
|
@ -686,6 +838,8 @@ export class ProjectService {
|
|||
await em.update(ProjectRelation, { projectId, userId }, { role: { slug: role } });
|
||||
await proxy.cleanupOrphanedEntriesForUsers([userId], em);
|
||||
});
|
||||
|
||||
await this.emitProjectMembersUpdated(user, projectId);
|
||||
}
|
||||
|
||||
async pruneRelations(em: EntityManager, project: Project) {
|
||||
|
|
@ -850,6 +1004,29 @@ export class ProjectService {
|
|||
});
|
||||
}
|
||||
|
||||
async getProjectMembersAndCount(
|
||||
projectId: string,
|
||||
{ offset, limit }: { offset: number; limit: number },
|
||||
): Promise<{ members: ProjectRelation[]; count: number }> {
|
||||
const [members, count] = await this.projectRelationRepository.findAndCount({
|
||||
where: { projectId },
|
||||
relations: { user: true, role: true },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
return { members, count };
|
||||
}
|
||||
|
||||
async getProjectScopesForUser(user: User, projectId: string): Promise<Scope[]> {
|
||||
const relation = await this.getProjectRelationForUserAndProject(user.id, projectId);
|
||||
return [
|
||||
...combineScopes({
|
||||
global: getAuthPrincipalScopes(user),
|
||||
project: relation?.role.scopes.map((scope) => scope.slug) ?? [],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async getUserOwnedOrAdminProjects(userId: string): Promise<Project[]> {
|
||||
return await this.projectRepository.find({
|
||||
where: {
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import {
|
|||
} from '@n8n/backend-test-utils';
|
||||
import { SharedWorkflowRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { License } from '@/license';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { LicenseMocker } from '@test-integration/license';
|
||||
|
||||
import { createUser } from './shared/db/users';
|
||||
|
||||
import { License } from '@/license';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
describe('ProjectService', () => {
|
||||
let projectService: ProjectService;
|
||||
let sharedWorkflowRepository: SharedWorkflowRepository;
|
||||
|
|
@ -53,7 +53,7 @@ describe('ProjectService', () => {
|
|||
|
||||
// ACT
|
||||
// add user again
|
||||
await projectService.addUsersToProject(project.id, [
|
||||
await projectService.addUsersToProject(user, project.id, [
|
||||
{ userId: user.id, role: 'project:admin' },
|
||||
]);
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ describe('ProjectService', () => {
|
|||
|
||||
// ACT
|
||||
// add user again
|
||||
await projectService.addUsersToProject(project.id, [
|
||||
await projectService.addUsersToProject(user, project.id, [
|
||||
{ userId: user.id, role: 'project:editor' },
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Container } from '@n8n/di';
|
|||
import type { MockInstance } from 'vitest';
|
||||
|
||||
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import {
|
||||
|
|
@ -24,12 +25,19 @@ describe('Projects in Public API', () => {
|
|||
const testServer = setupTestServer({ endpointGroups: ['publicApi'] });
|
||||
mockInstance(Telemetry);
|
||||
|
||||
let emitSpy: MockInstance<EventService['emit']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['Project', 'User']);
|
||||
emitSpy = vi.spyOn(Container.get(EventService), 'emit');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
emitSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('GET /projects', () => {
|
||||
|
|
@ -156,6 +164,11 @@ describe('Projects in Public API', () => {
|
|||
scopes: expect.any(Array),
|
||||
});
|
||||
await expect(getProjectByNameOrFail(projectPayload.name)).resolves.not.toThrow();
|
||||
expect(emitSpy).toHaveBeenCalledWith('team-project-created', {
|
||||
userId: owner.id,
|
||||
role: owner.role.slug,
|
||||
uiContext: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('if not authenticated, should reject', async () => {
|
||||
|
|
@ -249,6 +262,13 @@ describe('Projects in Public API', () => {
|
|||
*/
|
||||
expect(response.status).toBe(204);
|
||||
await expect(getProjectByNameOrFail(project.id)).rejects.toThrow();
|
||||
expect(emitSpy).toHaveBeenCalledWith('team-project-deleted', {
|
||||
userId: owner.id,
|
||||
role: owner.role.slug,
|
||||
projectId: project.id,
|
||||
removalType: 'delete',
|
||||
targetProjectId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('if not authenticated, should reject', async () => {
|
||||
|
|
@ -338,6 +358,11 @@ describe('Projects in Public API', () => {
|
|||
*/
|
||||
expect(response.status).toBe(204);
|
||||
await expect(getProjectByNameOrFail('new-name')).resolves.not.toThrow();
|
||||
expect(emitSpy).toHaveBeenCalledWith('team-project-updated', {
|
||||
userId: owner.id,
|
||||
role: owner.role.slug,
|
||||
projectId: project.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('if not authenticated, should reject', async () => {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ import { testDb } from '@n8n/backend-test-utils';
|
|||
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole, type Scope } from '@n8n/permissions';
|
||||
|
||||
import { License } from '@/license';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { createRole } from '@test-integration/db/roles';
|
||||
import { LicenseMocker } from '@test-integration/license';
|
||||
|
||||
import { createMember } from '../shared/db/users';
|
||||
|
||||
import { License } from '@/license';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
let projectRepository: ProjectRepository;
|
||||
let projectService: ProjectService;
|
||||
let projectRelationRepository: ProjectRelationRepository;
|
||||
|
|
@ -254,9 +254,9 @@ describe('ProjectService', () => {
|
|||
);
|
||||
await projectService.addUser(project.id, { userId: user.id, role });
|
||||
|
||||
await expect(projectService.deleteUserFromProject(project.id, user.id)).rejects.toThrowError(
|
||||
/^Project owner cannot be removed from the project$/,
|
||||
);
|
||||
await expect(
|
||||
projectService.deleteUserFromProject(user, project.id, user.id),
|
||||
).rejects.toThrowError(/^Project owner cannot be removed from the project$/);
|
||||
});
|
||||
|
||||
it('should remove user from project if not owner', async () => {
|
||||
|
|
@ -271,7 +271,7 @@ describe('ProjectService', () => {
|
|||
);
|
||||
await projectService.addUser(project.id, { userId: user.id, role });
|
||||
|
||||
await projectService.deleteUserFromProject(project.id, user.id);
|
||||
await projectService.deleteUserFromProject(user, project.id, user.id);
|
||||
|
||||
const relations = await projectRelationRepository.findOne({
|
||||
where: { userId: user.id, projectId: project.id, role: { slug: role } },
|
||||
|
|
@ -298,6 +298,7 @@ describe('ProjectService', () => {
|
|||
// ACT
|
||||
//
|
||||
await projectService.addUsersToProject(
|
||||
members[0],
|
||||
project.id,
|
||||
members.map((member) => ({ userId: member.id, role: 'project:editor' })),
|
||||
);
|
||||
|
|
@ -328,7 +329,7 @@ describe('ProjectService', () => {
|
|||
// ACT
|
||||
//
|
||||
await expect(
|
||||
projectService.addUsersToProject(project.id, [
|
||||
projectService.addUsersToProject(member, project.id, [
|
||||
{ userId: member.id, role: 'custom:non-existing' },
|
||||
]),
|
||||
).rejects.toThrowError('Role custom:non-existing does not exist');
|
||||
|
|
@ -404,7 +405,7 @@ describe('ProjectService', () => {
|
|||
// ACT
|
||||
//
|
||||
await projectService.addUser(project.id, { userId: user.id, role: 'project:viewer' });
|
||||
await projectService.changeUserRoleInProject(project.id, user.id, 'project:editor');
|
||||
await projectService.changeUserRoleInProject(user, project.id, user.id, 'project:editor');
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
|
|
@ -434,7 +435,7 @@ describe('ProjectService', () => {
|
|||
//
|
||||
await projectService.addUser(project.id, { userId: user.id, role: 'project:viewer' });
|
||||
await expect(
|
||||
projectService.changeUserRoleInProject(project.id, user.id, 'project:non-existing'),
|
||||
projectService.changeUserRoleInProject(user, project.id, user.id, 'project:non-existing'),
|
||||
).rejects.toThrowError('Role project:non-existing does not exist');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user