diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 8ce9cdb1d1e4e..08ee5aa454ee9 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -275,7 +275,7 @@ export class CredentialsService { if (typeof projectId === 'string' && project === null) { throw new BadRequestError( - "You don't have the permissions to save the workflow in this project.", + "You don't have the permissions to save the credential in this project.", ); } diff --git a/packages/cli/src/databases/entities/ProjectRelation.ts b/packages/cli/src/databases/entities/ProjectRelation.ts index e66a7711207ba..2246dba0eaf3c 100644 --- a/packages/cli/src/databases/entities/ProjectRelation.ts +++ b/packages/cli/src/databases/entities/ProjectRelation.ts @@ -4,7 +4,11 @@ import { WithTimestamps } from './AbstractEntity'; import { Project } from './Project'; // personalOwner is only used for personal projects -export type ProjectRole = 'project:personalOwner' | 'project:admin' | 'project:editor'; +export type ProjectRole = + | 'project:personalOwner' + | 'project:admin' + | 'project:editor' + | 'project:viewer'; @Entity() export class ProjectRelation extends WithTimestamps { diff --git a/packages/cli/src/permissions/project-roles.ts b/packages/cli/src/permissions/project-roles.ts index 159a2af45246a..96e4dfff8a475 100644 --- a/packages/cli/src/permissions/project-roles.ts +++ b/packages/cli/src/permissions/project-roles.ts @@ -61,3 +61,12 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [ 'project:list', 'project:read', ]; + +export const PROJECT_VIEWER_SCOPES: Scope[] = [ + 'credential:list', + 'credential:read', + 'project:list', + 'project:read', + 'workflow:list', + 'workflow:read', +]; diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index e9fa17eb68edf..09841422e670e 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -13,6 +13,7 @@ import { import { PERSONAL_PROJECT_OWNER_SCOPES, PROJECT_EDITOR_SCOPES, + PROJECT_VIEWER_SCOPES, REGULAR_PROJECT_ADMIN_SCOPES, } from '@/permissions/project-roles'; import { @@ -39,6 +40,7 @@ const PROJECT_SCOPE_MAP: Record = { 'project:admin': REGULAR_PROJECT_ADMIN_SCOPES, 'project:personalOwner': PERSONAL_PROJECT_OWNER_SCOPES, 'project:editor': PROJECT_EDITOR_SCOPES, + 'project:viewer': PROJECT_VIEWER_SCOPES, }; const CREDENTIALS_SHARING_SCOPE_MAP: Record = { @@ -87,6 +89,7 @@ const ROLE_NAMES: Record< 'project:personalOwner': 'Project Owner', 'project:admin': 'Project Admin', 'project:editor': 'Project Editor', + 'project:viewer': 'Project Viewer', 'credential:user': 'Credential User', 'credential:owner': 'Credential Owner', 'workflow:owner': 'Workflow Owner', @@ -230,6 +233,8 @@ export class RoleService { return this.license.isProjectRoleAdminLicensed(); case 'project:editor': return this.license.isProjectRoleEditorLicensed(); + case 'project:viewer': + return this.license.isProjectRoleViewerLicensed(); case 'global:admin': return this.license.isAdvancedPermissionsLicensed(); default: diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index a0330949f8d1c..15db5def7eb6e 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -29,7 +29,6 @@ import { } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; import { mockInstance } from '../../shared/mocking'; - import { createTeamProject, linkUserToProject } from '../shared/db/projects'; const testServer = utils.setupTestServer({ @@ -82,6 +81,23 @@ afterEach(() => { jest.clearAllMocks(); }); +describe('POST /credentials', () => { + test('project viewers cannot create credentials', async () => { + const teamProject = await createTeamProject(); + await linkUserToProject(member, teamProject, 'project:viewer'); + + const response = await testServer + .authAgentFor(member) + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: teamProject.id }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); +}); + // ---------------------------------------- // GET /credentials - fetch all credentials // ---------------------------------------- @@ -231,6 +247,31 @@ describe('GET /credentials', () => { // GET /credentials/:id - fetch a certain credential // ---------------------------------------- describe('GET /credentials/:id', () => { + test('project viewers can view credentials', async () => { + const teamProject = await createTeamProject(); + await linkUserToProject(member, teamProject, 'project:viewer'); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: teamProject, + }); + + const response = await testServer + .authAgentFor(member) + .get(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toMatchObject({ + id: savedCredential.id, + shared: [{ projectId: teamProject.id, role: 'credential:owner' }], + homeProject: { + id: teamProject.id, + }, + sharedWithProjects: [], + scopes: ['credential:read'], + }); + expect(response.body.data.data).toBeUndefined(); + }); + test('should retrieve owned cred for owner', async () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); @@ -387,6 +428,35 @@ describe('GET /credentials/:id', () => { }); }); +describe('PATCH /credentials/:id', () => { + test('project viewer cannot update credentials', async () => { + // + // ARRANGE + // + const teamProject = await createTeamProject('', member); + await linkUserToProject(member, teamProject, 'project:viewer'); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: teamProject, + }); + + // + // ACT + // + const response = await testServer + .authAgentFor(member) + .patch(`/credentials/${savedCredential.id}`) + .send({ ...randomCredentialPayload() }); + + // + // ASSERT + // + + expect(response.statusCode).toBe(403); + expect(response.body.message).toBe('User is missing a scope required to perform this action'); + }); +}); + // ---------------------------------------- // idempotent share/unshare // ---------------------------------------- diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index 8e91a9dc6923c..d11eac4cd7812 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -685,7 +685,7 @@ describe('POST /credentials', () => { // .expect(400, { code: 400, - message: "You don't have the permissions to save the workflow in this project.", + message: "You don't have the permissions to save the credential in this project.", }); }); }); diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts index 866a680ccadd3..23c4cd836eb09 100644 --- a/packages/cli/test/integration/executions.controller.test.ts +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -7,6 +7,7 @@ import * as testDb from './shared/testDb'; import { setupTestServer } from './shared/utils'; import { mockInstance } from '../shared/mocking'; import { WaitTracker } from '@/WaitTracker'; +import { createTeamProject, linkUserToProject } from './shared/db/projects'; const testServer = setupTestServer({ endpointGroups: ['executions'] }); @@ -45,6 +46,23 @@ describe('GET /executions', () => { }); describe('GET /executions/:id', () => { + test('project viewers can view executions for workflows in the project', async () => { + // if sharing is not enabled, we're only returning the executions of + // personal workflows + testServer.license.enable('feat:sharing'); + + const teamProject = await createTeamProject(); + await linkUserToProject(member, teamProject, 'project:viewer'); + + const workflow = await createWorkflow({}, teamProject); + const execution = await createSuccessfulExecution(workflow); + + const response = await testServer.authAgentFor(member).get(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeDefined(); + }); + test('only returns executions of shared workflows if sharing is enabled', async () => { const workflow = await createWorkflow({}, owner); await shareWorkflowWithUsers(workflow, [member]); diff --git a/packages/cli/test/integration/project.api.test.ts b/packages/cli/test/integration/project.api.test.ts index a2371014d9224..d275362629afa 100644 --- a/packages/cli/test/integration/project.api.test.ts +++ b/packages/cli/test/integration/project.api.test.ts @@ -479,261 +479,265 @@ describe('PATCH /projects/:projectId', () => { const updatedProject = await findProject(personalProject.id); expect(updatedProject.name).not.toEqual('New Name'); }); -}); - -describe('PATCH /projects/:projectId', () => { - test('should add or remove users from a project', async () => { - const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ - createOwner(), - createUser(), - createUser(), - createUser(), - ]); - const [teamProject1, teamProject2] = await Promise.all([ - createTeamProject(undefined, testUser1), - createTeamProject(undefined, testUser2), - ]); - const [credential1, credential2] = await Promise.all([ - saveCredential(randomCredentialPayload(), { - role: 'credential:owner', - project: teamProject1, - }), - saveCredential(randomCredentialPayload(), { - role: 'credential:owner', - project: teamProject2, - }), - saveCredential(randomCredentialPayload(), { - role: 'credential:owner', - project: teamProject2, - }), - ]); - await shareCredentialWithProjects(credential2, [teamProject1]); - - await linkUserToProject(ownerUser, teamProject2, 'project:editor'); - await linkUserToProject(testUser2, teamProject2, 'project:editor'); - - const memberAgent = testServer.authAgentFor(testUser1); - - const deleteSpy = jest.spyOn(Container.get(CacheService), 'deleteMany'); - const resp = await memberAgent.patch(`/projects/${teamProject1.id}`).send({ - name: teamProject1.name, - relations: [ - { userId: testUser1.id, role: 'project:admin' }, - { userId: testUser3.id, role: 'project:editor' }, - { userId: ownerUser.id, role: 'project:viewer' }, - ] as Array<{ - userId: string; - role: ProjectRole; - }>, - }); - expect(resp.status).toBe(200); - - expect(deleteSpy).toBeCalledWith([`credential-can-use-secrets:${credential1.id}`]); - deleteSpy.mockClear(); - - const [tp1Relations, tp2Relations] = await Promise.all([ - getProjectRelations({ projectId: teamProject1.id }), - getProjectRelations({ projectId: teamProject2.id }), - ]); - - expect(tp1Relations.length).toBe(3); - expect(tp2Relations.length).toBe(2); - - expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); - expect(tp1Relations.find((p) => p.userId === testUser2.id)).toBeUndefined(); - expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); - expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role).toBe('project:editor'); - expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:viewer'); - - // Check we haven't modified the other team project - expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); - expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined(); - expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor'); - expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:editor'); - }); - - test('should not add or remove users from a project if lacking permissions', async () => { - const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ - createOwner(), - createUser(), - createUser(), - createUser(), - ]); - const [teamProject1, teamProject2] = await Promise.all([ - createTeamProject(undefined, testUser2), - createTeamProject(), - ]); - - await linkUserToProject(testUser1, teamProject1, 'project:viewer'); - await linkUserToProject(ownerUser, teamProject2, 'project:editor'); - await linkUserToProject(testUser2, teamProject2, 'project:editor'); - - const memberAgent = testServer.authAgentFor(testUser1); - const resp = await memberAgent.patch(`/projects/${teamProject1.id}`).send({ - name: teamProject1.name, - relations: [ - { userId: testUser1.id, role: 'project:admin' }, - { userId: testUser3.id, role: 'project:editor' }, - { userId: ownerUser.id, role: 'project:viewer' }, - ] as Array<{ - userId: string; - role: ProjectRole; - }>, + describe('member management', () => { + test('should add or remove users from a project', async () => { + const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([ + createOwner(), + createUser(), + createUser(), + createUser(), + ]); + const [teamProject1, teamProject2] = await Promise.all([ + createTeamProject(undefined, testUser1), + createTeamProject(undefined, testUser2), + ]); + const [credential1, credential2] = await Promise.all([ + saveCredential(randomCredentialPayload(), { + role: 'credential:owner', + project: teamProject1, + }), + saveCredential(randomCredentialPayload(), { + role: 'credential:owner', + project: teamProject2, + }), + saveCredential(randomCredentialPayload(), { + role: 'credential:owner', + project: teamProject2, + }), + ]); + await shareCredentialWithProjects(credential2, [teamProject1]); + + await linkUserToProject(ownerUser, teamProject2, 'project:editor'); + await linkUserToProject(testUser2, teamProject2, 'project:editor'); + + const memberAgent = testServer.authAgentFor(testUser1); + + const deleteSpy = jest.spyOn(Container.get(CacheService), 'deleteMany'); + const resp = await memberAgent.patch(`/projects/${teamProject1.id}`).send({ + name: teamProject1.name, + relations: [ + { userId: testUser1.id, role: 'project:admin' }, + { userId: testUser3.id, role: 'project:editor' }, + { userId: ownerUser.id, role: 'project:viewer' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(200); + + expect(deleteSpy).toBeCalledWith([`credential-can-use-secrets:${credential1.id}`]); + deleteSpy.mockClear(); + + const [tp1Relations, tp2Relations] = await Promise.all([ + getProjectRelations({ projectId: teamProject1.id }), + getProjectRelations({ projectId: teamProject2.id }), + ]); + + expect(tp1Relations.length).toBe(3); + expect(tp2Relations.length).toBe(2); + + expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tp1Relations.find((p) => p.userId === testUser2.id)).toBeUndefined(); + expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); + expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role).toBe('project:editor'); + expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:viewer'); + + // Check we haven't modified the other team project + expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined(); + expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor'); + expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:editor'); }); - expect(resp.status).toBe(403); - - const [tp1Relations, tp2Relations] = await Promise.all([ - getProjectRelations({ projectId: teamProject1.id }), - getProjectRelations({ projectId: teamProject2.id }), - ]); - expect(tp1Relations.length).toBe(2); - expect(tp2Relations.length).toBe(2); - - expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); - expect(tp1Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); - expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer'); - expect(tp1Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); - expect(tp1Relations.find((p) => p.userId === testUser3.id)).toBeUndefined(); - - // Check we haven't modified the other team project - expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); - expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined(); - expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role).toBe('project:editor'); - expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role).toBe('project:editor'); - }); - - test('should not add from a project adding user with an unlicensed role', async () => { - testServer.license.disable('feat:projectRole:editor'); - const [testUser1, testUser2, testUser3] = await Promise.all([ - createUser(), - createUser(), - createUser(), - ]); - const teamProject = await createTeamProject(undefined, testUser2); - - await linkUserToProject(testUser1, teamProject, 'project:admin'); - - const memberAgent = testServer.authAgentFor(testUser2); - - const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ - name: teamProject.name, - relations: [ - { userId: testUser2.id, role: 'project:admin' }, - { userId: testUser1.id, role: 'project:editor' }, - ] as Array<{ - userId: string; - role: ProjectRole; - }>, + test.each([['project:viewer'], ['project:editor']] as const)( + '`%s`s should not be able to add, update or remove users from a project', + async (role) => { + // + // ARRANGE + // + const [actor, projectEditor, userToBeInvited] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const teamProject1 = await createTeamProject(); + + await linkUserToProject(actor, teamProject1, role); + await linkUserToProject(projectEditor, teamProject1, 'project:editor'); + + // + // ACT + // + const response = await testServer + .authAgentFor(actor) + .patch(`/projects/${teamProject1.id}`) + .send({ + name: teamProject1.name, + relations: [ + // update the viewer to be the project admin + { userId: actor.id, role: 'project:admin' }, + // add a user to the project + { userId: userToBeInvited.id, role: 'project:editor' }, + // implicitly remove the project editor + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + //.expect(403); + + // + // ASSERT + // + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + message: 'User is missing a scope required to perform this action', + }); + const tp1Relations = await getProjectRelations({ projectId: teamProject1.id }); + + expect(tp1Relations.length).toBe(2); + expect(tp1Relations).toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ userId: actor.id, role }), + expect.objectContaining({ userId: projectEditor.id, role: 'project:editor' }), + ]), + ); + }, + ); + + test.each([ + ['project:viewer', 'feat:projectRole:viewer'], + ['project:editor', 'feat:projectRole:editor'], + ] as const)( + "should not be able to add a user with the role %s if it's not licensed", + async (role, feature) => { + testServer.license.disable(feature); + const [projectAdmin, userToBeInvited] = await Promise.all([createUser(), createUser()]); + const teamProject = await createTeamProject('Team Project', projectAdmin); + + await testServer + .authAgentFor(projectAdmin) + .patch(`/projects/${teamProject.id}`) + .send({ + name: teamProject.name, + relations: [ + { userId: projectAdmin.id, role: 'project:admin' }, + { userId: userToBeInvited.id, role }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }) + .expect(400); + + const tpRelations = await getProjectRelations({ projectId: teamProject.id }); + expect(tpRelations.length).toBe(1); + expect(tpRelations).toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ userId: projectAdmin.id, role: 'project:admin' }), + ]), + ); + }, + ); + + test("should not edit a relation of a project when changing a user's role to an unlicensed role", async () => { + testServer.license.disable('feat:projectRole:editor'); + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const teamProject = await createTeamProject(undefined, testUser2); + + await linkUserToProject(testUser1, teamProject, 'project:admin'); + await linkUserToProject(testUser3, teamProject, 'project:admin'); + + const memberAgent = testServer.authAgentFor(testUser2); + + const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ + name: teamProject.name, + relations: [ + { userId: testUser2.id, role: 'project:admin' }, + { userId: testUser1.id, role: 'project:editor' }, + { userId: testUser3.id, role: 'project:editor' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(400); + + const tpRelations = await getProjectRelations({ projectId: teamProject.id }); + expect(tpRelations.length).toBe(3); + + expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); }); - expect(resp.status).toBe(400); - const tpRelations = await getProjectRelations({ projectId: teamProject.id }); - expect(tpRelations.length).toBe(2); - - expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); - expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); - expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); - expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); - expect(tpRelations.find((p) => p.userId === testUser3.id)).toBeUndefined(); - }); - - test("should not edit a relation of a project when changing a user's role to an unlicensed role", async () => { - testServer.license.disable('feat:projectRole:editor'); - const [testUser1, testUser2, testUser3] = await Promise.all([ - createUser(), - createUser(), - createUser(), - ]); - const teamProject = await createTeamProject(undefined, testUser2); - - await linkUserToProject(testUser1, teamProject, 'project:admin'); - await linkUserToProject(testUser3, teamProject, 'project:admin'); - - const memberAgent = testServer.authAgentFor(testUser2); - - const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ - name: teamProject.name, - relations: [ - { userId: testUser2.id, role: 'project:admin' }, - { userId: testUser1.id, role: 'project:editor' }, - { userId: testUser3.id, role: 'project:editor' }, - ] as Array<{ - userId: string; - role: ProjectRole; - }>, - }); - expect(resp.status).toBe(400); - - const tpRelations = await getProjectRelations({ projectId: teamProject.id }); - expect(tpRelations.length).toBe(3); - - expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); - expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); - expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:admin'); - expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); - expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); - }); - - test("should edit a relation of a project when changing a user's role to an licensed role but unlicensed roles are present", async () => { - testServer.license.disable('feat:projectRole:viewer'); - const [testUser1, testUser2, testUser3] = await Promise.all([ - createUser(), - createUser(), - createUser(), - ]); - const teamProject = await createTeamProject(undefined, testUser2); - - await linkUserToProject(testUser1, teamProject, 'project:viewer'); - await linkUserToProject(testUser3, teamProject, 'project:editor'); - - const memberAgent = testServer.authAgentFor(testUser2); - - const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ - name: teamProject.name, - relations: [ - { userId: testUser1.id, role: 'project:viewer' }, - { userId: testUser2.id, role: 'project:admin' }, - { userId: testUser3.id, role: 'project:admin' }, - ] as Array<{ - userId: string; - role: ProjectRole; - }>, + test("should edit a relation of a project when changing a user's role to an licensed role but unlicensed roles are present", async () => { + testServer.license.disable('feat:projectRole:viewer'); + const [testUser1, testUser2, testUser3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + const teamProject = await createTeamProject(undefined, testUser2); + + await linkUserToProject(testUser1, teamProject, 'project:viewer'); + await linkUserToProject(testUser3, teamProject, 'project:editor'); + + const memberAgent = testServer.authAgentFor(testUser2); + + const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ + name: teamProject.name, + relations: [ + { userId: testUser1.id, role: 'project:viewer' }, + { userId: testUser2.id, role: 'project:admin' }, + { userId: testUser3.id, role: 'project:admin' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(200); + + const tpRelations = await getProjectRelations({ projectId: teamProject.id }); + expect(tpRelations.length).toBe(3); + + expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser3.id)).not.toBeUndefined(); + expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer'); + expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); + expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); }); - expect(resp.status).toBe(200); - const tpRelations = await getProjectRelations({ projectId: teamProject.id }); - expect(tpRelations.length).toBe(3); + test('should not add or remove users from a personal project', async () => { + const [testUser1, testUser2] = await Promise.all([createUser(), createUser()]); - expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined(); - expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined(); - expect(tpRelations.find((p) => p.userId === testUser3.id)).not.toBeUndefined(); - expect(tpRelations.find((p) => p.userId === testUser1.id)?.role).toBe('project:viewer'); - expect(tpRelations.find((p) => p.userId === testUser2.id)?.role).toBe('project:admin'); - expect(tpRelations.find((p) => p.userId === testUser3.id)?.role).toBe('project:admin'); - }); - - test('should not add or remove users from a personal project', async () => { - const [testUser1, testUser2] = await Promise.all([createUser(), createUser()]); + const personalProject = await getPersonalProject(testUser1); - const personalProject = await getPersonalProject(testUser1); + const memberAgent = testServer.authAgentFor(testUser1); - const memberAgent = testServer.authAgentFor(testUser1); + const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({ + relations: [ + { userId: testUser1.id, role: 'project:personalOwner' }, + { userId: testUser2.id, role: 'project:admin' }, + ] as Array<{ + userId: string; + role: ProjectRole; + }>, + }); + expect(resp.status).toBe(403); - const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({ - relations: [ - { userId: testUser1.id, role: 'project:personalOwner' }, - { userId: testUser2.id, role: 'project:admin' }, - ] as Array<{ - userId: string; - role: ProjectRole; - }>, + const p1Relations = await getProjectRelations({ projectId: personalProject.id }); + expect(p1Relations.length).toBe(1); }); - expect(resp.status).toBe(403); - - const p1Relations = await getProjectRelations({ projectId: personalProject.id }); - expect(p1Relations.length).toBe(1); }); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index d87b143ee07be..b7367d51788e3 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -321,6 +321,24 @@ describe('GET /workflows/:workflowId', () => { expect(response.statusCode).toBe(404); }); + test('project viewers can view workflows', async () => { + const teamProject = await createTeamProject(); + await linkUserToProject(member, teamProject, 'project:viewer'); + + const workflow = await createWorkflow({}, teamProject); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}`).expect(200); + const responseWorkflow: WorkflowWithSharingsMetaDataAndCredentials = response.body.data; + + expect(responseWorkflow.homeProject).toMatchObject({ + id: teamProject.id, + name: teamProject.name, + type: 'team', + }); + + expect(responseWorkflow.sharedWithProjects).toHaveLength(0); + }); + test('should return a workflow with owner', async () => { const workflow = await createWorkflow({}, owner); @@ -512,6 +530,20 @@ describe('GET /workflows/:workflowId', () => { }); describe('POST /workflows', () => { + test('project viewers cannot create workflows', async () => { + const teamProject = await createTeamProject(); + await linkUserToProject(member, teamProject, 'project:viewer'); + + const response = await authMemberAgent + .post('/workflows') + .send({ ...makeWorkflow(), projectId: teamProject.id }); + + expect(response.body).toMatchObject({ + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); + it('Should create a workflow that uses no credential', async () => { const workflow = makeWorkflow({ withPinData: false }); @@ -665,76 +697,178 @@ describe('POST /workflows', () => { }); }); -describe('PATCH /workflows/:workflowId - validate credential permissions to user', () => { - it('Should succeed when saving unchanged workflow nodes', async () => { - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - const workflow = { - name: 'test', - active: false, - connections: {}, - nodes: [ - { - id: 'uuid-1234', - name: 'Start', - parameters: {}, - position: [-20, 260], - type: 'n8n-nodes-base.start', - typeVersion: 1, - credentials: { - default: { - id: savedCredential.id, - name: savedCredential.name, +describe('PATCH /workflows/:workflowId', () => { + test('project viewers cannot update workflows', async () => { + const teamProject = await createTeamProject(); + await linkUserToProject(member, teamProject, 'project:viewer'); + + const workflow = await createWorkflow({ name: 'WF Name' }, teamProject); + + const response = await authMemberAgent + .patch(`/workflows/${workflow.id}`) + .send({ ...workflow, name: 'New Name' }); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + message: 'User is missing a scope required to perform this action', + }); + }); + + describe('validate credential permissions to user', () => { + it('Should succeed when saving unchanged workflow nodes', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + const workflow = { + name: 'test', + active: false, + connections: {}, + nodes: [ + { + id: 'uuid-1234', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: { + default: { + id: savedCredential.id, + name: savedCredential.name, + }, }, }, - }, - ], - }; + ], + }; - const createResponse = await authOwnerAgent.post('/workflows').send(workflow); - const { id, versionId } = createResponse.body.data; + const createResponse = await authOwnerAgent.post('/workflows').send(workflow); + const { id, versionId } = createResponse.body.data; - const response = await authOwnerAgent.patch(`/workflows/${id}`).send({ - name: 'new name', - versionId, + const response = await authOwnerAgent.patch(`/workflows/${id}`).send({ + name: 'new name', + versionId, + }); + + expect(response.statusCode).toBe(200); }); - expect(response.statusCode).toBe(200); - }); + it('Should allow owner to add node containing credential not shared with the owner', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + const workflow = { + name: 'test', + active: false, + connections: {}, + nodes: [ + { + id: 'uuid-1234', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: { + default: { + id: savedCredential.id, + name: savedCredential.name, + }, + }, + }, + ], + }; + + const createResponse = await authOwnerAgent.post('/workflows').send(workflow); + const { id, versionId } = createResponse.body.data; + + const response = await authOwnerAgent.patch(`/workflows/${id}`).send({ + versionId, + nodes: [ + { + id: 'uuid-1234', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: { + default: { + id: savedCredential.id, + name: savedCredential.name, + }, + }, + }, + ], + }); - it('Should allow owner to add node containing credential not shared with the owner', async () => { - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - const workflow = { - name: 'test', - active: false, - connections: {}, - nodes: [ - { - id: 'uuid-1234', - name: 'Start', - parameters: {}, - position: [-20, 260], - type: 'n8n-nodes-base.start', - typeVersion: 1, - credentials: { - default: { - id: savedCredential.id, - name: savedCredential.name, + expect(response.statusCode).toBe(200); + }); + + it('Should prevent member from adding node containing credential inaccessible to member', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const workflow = { + name: 'test', + active: false, + connections: {}, + nodes: [ + { + id: 'uuid-1234', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: { + default: { + id: savedCredential.id, + name: savedCredential.name, + }, }, }, - }, - ], - }; + ], + }; + + const createResponse = await authOwnerAgent.post('/workflows').send(workflow); + const { id, versionId } = createResponse.body.data; + + const response = await authMemberAgent.patch(`/workflows/${id}`).send({ + versionId, + nodes: [ + { + id: 'uuid-1234', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: {}, + }, + { + id: 'uuid-12345', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: { + default: { + id: savedCredential.id, + name: savedCredential.name, + }, + }, + }, + ], + }); + expect(response.statusCode).toBe(403); + }); - const createResponse = await authOwnerAgent.post('/workflows').send(workflow); - const { id, versionId } = createResponse.body.data; + it('Should succeed but prevent modifying node attributes other than position, name and disabled', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - const response = await authOwnerAgent.patch(`/workflows/${id}`).send({ - versionId, - nodes: [ + const originalNodes: INode[] = [ { id: 'uuid-1234', name: 'Start', - parameters: {}, + parameters: { + firstParam: 123, + }, position: [-20, 260], type: 'n8n-nodes-base.start', typeVersion: 1, @@ -745,57 +879,36 @@ describe('PATCH /workflows/:workflowId - validate credential permissions to user }, }, }, - ], - }); + ]; - expect(response.statusCode).toBe(200); - }); - - it('Should prevent member from adding node containing credential inaccessible to member', async () => { - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - - const workflow = { - name: 'test', - active: false, - connections: {}, - nodes: [ + const changedNodes: INode[] = [ { id: 'uuid-1234', - name: 'Start', - parameters: {}, - position: [-20, 260], - type: 'n8n-nodes-base.start', + name: 'End', + parameters: { + firstParam: 456, + }, + position: [-20, 555], + type: 'n8n-nodes-base.no-op', typeVersion: 1, credentials: { default: { - id: savedCredential.id, - name: savedCredential.name, + id: '200', + name: 'fake credential', }, }, + disabled: true, }, - ], - }; + ]; - const createResponse = await authOwnerAgent.post('/workflows').send(workflow); - const { id, versionId } = createResponse.body.data; - - const response = await authMemberAgent.patch(`/workflows/${id}`).send({ - versionId, - nodes: [ + const expectedNodes: INode[] = [ { id: 'uuid-1234', - name: 'Start', - parameters: {}, - position: [-20, 260], - type: 'n8n-nodes-base.start', - typeVersion: 1, - credentials: {}, - }, - { - id: 'uuid-12345', - name: 'Start', - parameters: {}, - position: [-20, 260], + name: 'End', + parameters: { + firstParam: 123, + }, + position: [-20, 555], type: 'n8n-nodes-base.start', typeVersion: 1, credentials: { @@ -804,442 +917,379 @@ describe('PATCH /workflows/:workflowId - validate credential permissions to user name: savedCredential.name, }, }, + disabled: true, }, - ], - }); - expect(response.statusCode).toBe(403); - }); + ]; - it('Should succeed but prevent modifying node attributes other than position, name and disabled', async () => { - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + const workflow = { + name: 'test', + active: false, + connections: {}, + nodes: originalNodes, + }; - const originalNodes: INode[] = [ - { - id: 'uuid-1234', - name: 'Start', - parameters: { - firstParam: 123, - }, - position: [-20, 260], - type: 'n8n-nodes-base.start', - typeVersion: 1, - credentials: { - default: { - id: savedCredential.id, - name: savedCredential.name, - }, - }, - }, - ]; + const createResponse = await authMemberAgent.post('/workflows').send(workflow); + const { id, versionId } = createResponse.body.data; - const changedNodes: INode[] = [ - { - id: 'uuid-1234', - name: 'End', - parameters: { - firstParam: 456, - }, - position: [-20, 555], - type: 'n8n-nodes-base.no-op', - typeVersion: 1, - credentials: { - default: { - id: '200', - name: 'fake credential', - }, - }, - disabled: true, - }, - ]; - - const expectedNodes: INode[] = [ - { - id: 'uuid-1234', - name: 'End', - parameters: { - firstParam: 123, - }, - position: [-20, 555], - type: 'n8n-nodes-base.start', - typeVersion: 1, - credentials: { - default: { - id: savedCredential.id, - name: savedCredential.name, - }, - }, - disabled: true, - }, - ]; - - const workflow = { - name: 'test', - active: false, - connections: {}, - nodes: originalNodes, - }; - - const createResponse = await authMemberAgent.post('/workflows').send(workflow); - const { id, versionId } = createResponse.body.data; + await authMemberAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }) + .expect(200); - await authMemberAgent - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [anotherMemberPersonalProject.id] }) - .expect(200); + const response = await authAnotherMemberAgent.patch(`/workflows/${id}`).send({ + versionId, + nodes: changedNodes, + }); - const response = await authAnotherMemberAgent.patch(`/workflows/${id}`).send({ - versionId, - nodes: changedNodes, + expect(response.statusCode).toBe(200); + expect(response.body.data.nodes).toMatchObject(expectedNodes); }); - - expect(response.statusCode).toBe(200); - expect(response.body.data.nodes).toMatchObject(expectedNodes); }); -}); -describe('PATCH /workflows/:workflowId - validate interim updates', () => { - it('should block owner updating workflow nodes on interim update by member', async () => { - // owner creates and shares workflow + describe('validate interim updates', () => { + it('should block owner updating workflow nodes on interim update by member', async () => { + // owner creates and shares workflow - const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); - const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [memberPersonalProject.id] }); + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); + const { id, versionId: ownerVersionId } = createResponse.body.data; + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); - // member accesses and updates workflow name + // member accesses and updates workflow name - const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); - const { versionId: memberVersionId } = memberGetResponse.body.data; + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); + const { versionId: memberVersionId } = memberGetResponse.body.data; - await authMemberAgent - .patch(`/workflows/${id}`) - .send({ name: 'Update by member', versionId: memberVersionId }); + await authMemberAgent + .patch(`/workflows/${id}`) + .send({ name: 'Update by member', versionId: memberVersionId }); - // owner blocked from updating workflow nodes + // owner blocked from updating workflow nodes - const updateAttemptResponse = await authOwnerAgent - .patch(`/workflows/${id}`) - .send({ nodes: [], versionId: ownerVersionId }); + const updateAttemptResponse = await authOwnerAgent + .patch(`/workflows/${id}`) + .send({ nodes: [], versionId: ownerVersionId }); - expect(updateAttemptResponse.status).toBe(400); - expect(updateAttemptResponse.body.code).toBe(100); - }); + expect(updateAttemptResponse.status).toBe(400); + expect(updateAttemptResponse.body.code).toBe(100); + }); - it('should block member updating workflow nodes on interim update by owner', async () => { - // owner creates, updates and shares workflow + it('should block member updating workflow nodes on interim update by owner', async () => { + // owner creates, updates and shares workflow - const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); - const { id, versionId: ownerFirstVersionId } = createResponse.body.data; + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); + const { id, versionId: ownerFirstVersionId } = createResponse.body.data; - const updateResponse = await authOwnerAgent - .patch(`/workflows/${id}`) - .send({ name: 'Update by owner', versionId: ownerFirstVersionId }); + const updateResponse = await authOwnerAgent + .patch(`/workflows/${id}`) + .send({ name: 'Update by owner', versionId: ownerFirstVersionId }); - const { versionId: ownerSecondVersionId } = updateResponse.body.data; + const { versionId: ownerSecondVersionId } = updateResponse.body.data; - await authOwnerAgent - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [memberPersonalProject.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); - // member accesses workflow + // member accesses workflow - const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); - const { versionId: memberVersionId } = memberGetResponse.body.data; + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); + const { versionId: memberVersionId } = memberGetResponse.body.data; - // owner re-updates workflow + // owner re-updates workflow - await authOwnerAgent - .patch(`/workflows/${id}`) - .send({ name: 'Owner update again', versionId: ownerSecondVersionId }); + await authOwnerAgent + .patch(`/workflows/${id}`) + .send({ name: 'Owner update again', versionId: ownerSecondVersionId }); - // member blocked from updating workflow + // member blocked from updating workflow - const updateAttemptResponse = await authMemberAgent - .patch(`/workflows/${id}`) - .send({ nodes: [], versionId: memberVersionId }); + const updateAttemptResponse = await authMemberAgent + .patch(`/workflows/${id}`) + .send({ nodes: [], versionId: memberVersionId }); - expect(updateAttemptResponse.status).toBe(400); - expect(updateAttemptResponse.body.code).toBe(100); - }); + expect(updateAttemptResponse.status).toBe(400); + expect(updateAttemptResponse.body.code).toBe(100); + }); - it('should block owner activation on interim activation by member', async () => { - // owner creates and shares workflow + it('should block owner activation on interim activation by member', async () => { + // owner creates and shares workflow - const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); - const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [memberPersonalProject.id] }); + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); + const { id, versionId: ownerVersionId } = createResponse.body.data; + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); - // member accesses and activates workflow + // member accesses and activates workflow - const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); - const { versionId: memberVersionId } = memberGetResponse.body.data; - await authMemberAgent - .patch(`/workflows/${id}`) - .send({ active: true, versionId: memberVersionId, name: 'Update by member' }); - // owner blocked from activating workflow + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); + const { versionId: memberVersionId } = memberGetResponse.body.data; + await authMemberAgent + .patch(`/workflows/${id}`) + .send({ active: true, versionId: memberVersionId, name: 'Update by member' }); + // owner blocked from activating workflow - const activationAttemptResponse = await authOwnerAgent - .patch(`/workflows/${id}`) - .send({ active: true, versionId: ownerVersionId, name: 'Update by owner' }); + const activationAttemptResponse = await authOwnerAgent + .patch(`/workflows/${id}`) + .send({ active: true, versionId: ownerVersionId, name: 'Update by owner' }); - expect(activationAttemptResponse.status).toBe(400); - expect(activationAttemptResponse.body.code).toBe(100); - }); + expect(activationAttemptResponse.status).toBe(400); + expect(activationAttemptResponse.body.code).toBe(100); + }); - it('should block member activation on interim activation by owner', async () => { - // owner creates, updates and shares workflow + it('should block member activation on interim activation by owner', async () => { + // owner creates, updates and shares workflow - const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); - const { id, versionId: ownerFirstVersionId } = createResponse.body.data; + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); + const { id, versionId: ownerFirstVersionId } = createResponse.body.data; - const updateResponse = await authOwnerAgent - .patch(`/workflows/${id}`) - .send({ name: 'Update by owner', versionId: ownerFirstVersionId }); - const { versionId: ownerSecondVersionId } = updateResponse.body.data; + const updateResponse = await authOwnerAgent + .patch(`/workflows/${id}`) + .send({ name: 'Update by owner', versionId: ownerFirstVersionId }); + const { versionId: ownerSecondVersionId } = updateResponse.body.data; - await authOwnerAgent - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [memberPersonalProject.id] }); + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); - // member accesses workflow + // member accesses workflow - const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); - const { versionId: memberVersionId } = memberGetResponse.body.data; + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); + const { versionId: memberVersionId } = memberGetResponse.body.data; - // owner activates workflow + // owner activates workflow - await authOwnerAgent - .patch(`/workflows/${id}`) - .send({ active: true, versionId: ownerSecondVersionId, name: 'Owner update again' }); + await authOwnerAgent + .patch(`/workflows/${id}`) + .send({ active: true, versionId: ownerSecondVersionId, name: 'Owner update again' }); - // member blocked from activating workflow + // member blocked from activating workflow - const updateAttemptResponse = await authMemberAgent - .patch(`/workflows/${id}`) - .send({ active: true, versionId: memberVersionId, name: 'Update by member' }); + const updateAttemptResponse = await authMemberAgent + .patch(`/workflows/${id}`) + .send({ active: true, versionId: memberVersionId, name: 'Update by member' }); - expect(updateAttemptResponse.status).toBe(400); - expect(updateAttemptResponse.body.code).toBe(100); - }); + expect(updateAttemptResponse.status).toBe(400); + expect(updateAttemptResponse.body.code).toBe(100); + }); - it('should block member updating workflow settings on interim update by owner', async () => { - // owner creates and shares workflow + it('should block member updating workflow settings on interim update by owner', async () => { + // owner creates and shares workflow - const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); - const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [memberPersonalProject.id] }); + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); + const { id, versionId: ownerVersionId } = createResponse.body.data; + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); - // member accesses workflow + // member accesses workflow - const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); - const { versionId: memberVersionId } = memberGetResponse.body.data; + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); + const { versionId: memberVersionId } = memberGetResponse.body.data; - // owner updates workflow name + // owner updates workflow name - await authOwnerAgent - .patch(`/workflows/${id}`) - .send({ name: 'Another name', versionId: ownerVersionId }); + await authOwnerAgent + .patch(`/workflows/${id}`) + .send({ name: 'Another name', versionId: ownerVersionId }); - // member blocked from updating workflow settings + // member blocked from updating workflow settings - const updateAttemptResponse = await authMemberAgent - .patch(`/workflows/${id}`) - .send({ settings: { saveManualExecutions: true }, versionId: memberVersionId }); + const updateAttemptResponse = await authMemberAgent + .patch(`/workflows/${id}`) + .send({ settings: { saveManualExecutions: true }, versionId: memberVersionId }); - expect(updateAttemptResponse.status).toBe(400); - expect(updateAttemptResponse.body.code).toBe(100); - }); + expect(updateAttemptResponse.status).toBe(400); + expect(updateAttemptResponse.body.code).toBe(100); + }); - it('should block member updating workflow name on interim update by owner', async () => { - // owner creates and shares workflow + it('should block member updating workflow name on interim update by owner', async () => { + // owner creates and shares workflow - const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); - const { id, versionId: ownerVersionId } = createResponse.body.data; - await authOwnerAgent - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [memberPersonalProject.id] }); + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); + const { id, versionId: ownerVersionId } = createResponse.body.data; + await authOwnerAgent + .put(`/workflows/${id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); - // member accesses workflow + // member accesses workflow - const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`).expect(200); - const { versionId: memberVersionId } = memberGetResponse.body.data; + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`).expect(200); + const { versionId: memberVersionId } = memberGetResponse.body.data; - // owner updates workflow settings + // owner updates workflow settings - await authOwnerAgent - .patch(`/workflows/${id}`) - .send({ settings: { saveManualExecutions: true }, versionId: ownerVersionId }); + await authOwnerAgent + .patch(`/workflows/${id}`) + .send({ settings: { saveManualExecutions: true }, versionId: ownerVersionId }); - // member blocked from updating workflow name + // member blocked from updating workflow name - const updateAttemptResponse = await authMemberAgent - .patch(`/workflows/${id}`) - .send({ settings: { saveManualExecutions: true }, versionId: memberVersionId }); + const updateAttemptResponse = await authMemberAgent + .patch(`/workflows/${id}`) + .send({ settings: { saveManualExecutions: true }, versionId: memberVersionId }); - expect(updateAttemptResponse.status).toBe(400); - expect(updateAttemptResponse.body.code).toBe(100); + expect(updateAttemptResponse.status).toBe(400); + expect(updateAttemptResponse.body.code).toBe(100); + }); }); -}); -describe('PATCH /workflows/:workflowId - workflow history', () => { - test('Should create workflow history version when licensed', async () => { - license.enable('feat:workflowHistory'); - const workflow = await createWorkflow({}, owner); - const payload = { - name: 'name updated', - versionId: workflow.versionId, - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], - }, - { - id: 'uuid-1234', - parameters: {}, - name: 'Cron', - type: 'n8n-nodes-base.cron', - typeVersion: 1, - position: [400, 300], + describe('workflow history', () => { + test('Should create workflow history version when licensed', async () => { + license.enable('feat:workflowHistory'); + const workflow = await createWorkflow({}, owner); + const payload = { + name: 'name updated', + versionId: workflow.versionId, + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', }, - ], - connections: {}, - staticData: '{"id":1}', - settings: { - saveExecutionProgress: false, - saveManualExecutions: false, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }; + }; - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - const { - data: { id }, - } = response.body; + const { + data: { id }, + } = response.body; - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200); - expect(id).toBe(workflow.id); - expect( - await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(1); - const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ - where: { - workflowId: id, - }, + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(1); + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + }, + }); + expect(historyVersion).not.toBeNull(); + expect(historyVersion!.connections).toEqual(payload.connections); + expect(historyVersion!.nodes).toEqual(payload.nodes); }); - expect(historyVersion).not.toBeNull(); - expect(historyVersion!.connections).toEqual(payload.connections); - expect(historyVersion!.nodes).toEqual(payload.nodes); - }); - test('Should not create workflow history version when not licensed', async () => { - license.disable('feat:workflowHistory'); - const workflow = await createWorkflow({}, owner); - const payload = { - name: 'name updated', - versionId: workflow.versionId, - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], - }, - { - id: 'uuid-1234', - parameters: {}, - name: 'Cron', - type: 'n8n-nodes-base.cron', - typeVersion: 1, - position: [400, 300], + test('Should not create workflow history version when not licensed', async () => { + license.disable('feat:workflowHistory'); + const workflow = await createWorkflow({}, owner); + const payload = { + name: 'name updated', + versionId: workflow.versionId, + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', }, - ], - connections: {}, - staticData: '{"id":1}', - settings: { - saveExecutionProgress: false, - saveManualExecutions: false, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }; + }; - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - const { - data: { id }, - } = response.body; + const { + data: { id }, + } = response.body; - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200); - expect(id).toBe(workflow.id); - expect( - await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(0); + expect(id).toBe(workflow.id); + expect( + await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), + ).toBe(0); + }); }); -}); -describe('PATCH /workflows/:workflowId - activate workflow', () => { - test('should activate workflow without changing version ID', async () => { - license.disable('feat:workflowHistory'); - const workflow = await createWorkflow({}, owner); - const payload = { - versionId: workflow.versionId, - active: true, - }; + describe('activate workflow', () => { + test('should activate workflow without changing version ID', async () => { + license.disable('feat:workflowHistory'); + const workflow = await createWorkflow({}, owner); + const payload = { + versionId: workflow.versionId, + active: true, + }; - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - expect(response.statusCode).toBe(200); - expect(activeWorkflowManager.add).toBeCalled(); + expect(response.statusCode).toBe(200); + expect(activeWorkflowManager.add).toBeCalled(); - const { - data: { id, versionId, active }, - } = response.body; + const { + data: { id, versionId, active }, + } = response.body; - expect(id).toBe(workflow.id); - expect(versionId).toBe(workflow.versionId); - expect(active).toBe(true); - }); + expect(id).toBe(workflow.id); + expect(versionId).toBe(workflow.versionId); + expect(active).toBe(true); + }); - test('should deactivate workflow without changing version ID', async () => { - license.disable('feat:workflowHistory'); - const workflow = await createWorkflow({ active: true }, owner); - const payload = { - versionId: workflow.versionId, - active: false, - }; + test('should deactivate workflow without changing version ID', async () => { + license.disable('feat:workflowHistory'); + const workflow = await createWorkflow({ active: true }, owner); + const payload = { + versionId: workflow.versionId, + active: false, + }; - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - expect(response.statusCode).toBe(200); - expect(activeWorkflowManager.add).not.toBeCalled(); - expect(activeWorkflowManager.remove).toBeCalled(); + expect(response.statusCode).toBe(200); + expect(activeWorkflowManager.add).not.toBeCalled(); + expect(activeWorkflowManager.remove).toBeCalled(); - const { - data: { id, versionId, active }, - } = response.body; + const { + data: { id, versionId, active }, + } = response.body; - expect(id).toBe(workflow.id); - expect(versionId).toBe(workflow.versionId); - expect(active).toBe(false); + expect(id).toBe(workflow.id); + expect(versionId).toBe(workflow.versionId); + expect(active).toBe(false); + }); }); }); @@ -1551,3 +1601,21 @@ describe('PUT /:workflowId/transfer', () => { .expect(500); }); }); + +describe('POST /workflows/:workflowId/run', () => { + test('project viewers cannot run workflows', async () => { + const teamProject = await createTeamProject(); + await linkUserToProject(member, teamProject, 'project:viewer'); + + const workflow = await createWorkflow({}, teamProject); + + const response = await authMemberAgent + .post(`/workflows/${workflow.id}/run`) + .send({ workflowData: workflow }); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + message: 'User is missing a scope required to perform this action', + }); + }); +}); diff --git a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts index 8afc8bb12178f..8eb8b498145ef 100644 --- a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts +++ b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts @@ -62,7 +62,12 @@ describe('SharedCredentialsRepository', () => { role: In(['credential:owner', 'credential:user']), project: { projectRelations: { - role: In(['project:admin', 'project:personalOwner', 'project:editor']), + role: In([ + 'project:admin', + 'project:personalOwner', + 'project:editor', + 'project:viewer', + ]), userId: member.id, }, }, @@ -83,7 +88,12 @@ describe('SharedCredentialsRepository', () => { role: In(['credential:owner', 'credential:user']), project: { projectRelations: { - role: In(['project:admin', 'project:personalOwner', 'project:editor']), + role: In([ + 'project:admin', + 'project:personalOwner', + 'project:editor', + 'project:viewer', + ]), userId: member.id, }, },