diff --git a/packages/cli/src/commands/ldap/reset.ts b/packages/cli/src/commands/ldap/reset.ts index edbf988434..8d3eb8da94 100644 --- a/packages/cli/src/commands/ldap/reset.ts +++ b/packages/cli/src/commands/ldap/reset.ts @@ -110,7 +110,7 @@ export class Reset extends BaseCommand { } for (const credential of ownedCredentials) { - await Container.get(CredentialsService).delete(credential); + await Container.get(CredentialsService).delete(owner, credential.id); } await Container.get(AuthProviderSyncHistoryRepository).delete({ providerType: 'ldap' }); diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 3177c2c23b..80d38fcfcd 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -240,7 +240,7 @@ export class UsersController { } for (const credential of ownedCredentials) { - await this.credentialsService.delete(credential); + await this.credentialsService.delete(userToDelete, credential.id); } await this.userService.getManager().transaction(async (trx) => { diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 73888e1977..6b4fd8472a 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -251,7 +251,7 @@ export class CredentialsController { ); } - await this.credentialsService.delete(credential); + await this.credentialsService.delete(req.user, credential.id); this.eventService.emit('credentials-deleted', { user: req.user, diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 52a1a3e88d..18d01a198a 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -406,10 +406,26 @@ export class CredentialsService { return result; } - async delete(credentials: CredentialsEntity) { - await this.externalHooks.run('credentials.delete', [credentials.id]); + /** + * Deletes a credential. + * + * If the user does not have permission to delete the credential this does + * nothing and returns void. + */ + async delete(user: User, credentialId: string) { + await this.externalHooks.run('credentials.delete', [credentialId]); - await this.credentialsRepository.remove(credentials); + const credential = await this.sharedCredentialsRepository.findCredentialForUser( + credentialId, + user, + ['credential:delete'], + ); + + if (!credential) { + return; + } + + await this.credentialsRepository.remove(credential); } async test(user: User, credentials: ICredentialsDecrypted) { diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index fff60bd566..cc817368cd 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -26,6 +26,9 @@ describe('SourceControlImportService', () => { mock(), workflowRepository, mock(), + mock(), + mock(), + mock(), mock({ n8nFolder: '/mock/n8n' }), ); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index 56b58646c9..43a13ed6ba 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -1,10 +1,19 @@ +import type { SourceControlledFile } from '@n8n/api-types'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; +import type { TagEntity } from '@/databases/entities/tag-entity'; +import type { User } from '@/databases/entities/user'; +import type { Variables } from '@/databases/entities/variables'; +import type { TagRepository } from '@/databases/repositories/tag.repository'; import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee'; import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee'; +import type { SourceControlImportService } from '../source-control-import.service.ee'; +import type { ExportableCredential } from '../types/exportable-credential'; +import type { SourceControlWorkflowVersionId } from '../types/source-control-workflow-version-id'; + describe('SourceControlService', () => { const preferencesService = new SourceControlPreferencesService( Container.get(InstanceSettings), @@ -13,20 +22,25 @@ describe('SourceControlService', () => { mock(), mock(), ); + const sourceControlImportService = mock(); + const tagRepository = mock(); const sourceControlService = new SourceControlService( mock(), mock(), preferencesService, mock(), - mock(), - mock(), + sourceControlImportService, + tagRepository, mock(), ); + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(sourceControlService, 'sanityCheck').mockResolvedValue(undefined); + }); + describe('pushWorkfolder', () => { it('should throw an error if a file is given that is not in the workfolder', async () => { - jest.spyOn(sourceControlService, 'sanityCheck').mockResolvedValue(undefined); - await expect( sourceControlService.pushWorkfolder({ fileNames: [ @@ -46,4 +60,155 @@ describe('SourceControlService', () => { ).rejects.toThrow('File path /etc/passwd is invalid'); }); }); + + describe('pullWorkfolder', () => { + it('does not filter locally created credentials', async () => { + // ARRANGE + const user = mock(); + const statuses = [ + mock({ + status: 'created', + location: 'local', + type: 'credential', + }), + mock({ + status: 'created', + location: 'local', + type: 'workflow', + }), + ]; + jest.spyOn(sourceControlService, 'getStatus').mockResolvedValueOnce(statuses); + + // ACT + const result = await sourceControlService.pullWorkfolder(user, {}); + + // ASSERT + expect(result).toMatchObject({ statusCode: 409, statusResult: statuses }); + }); + + it('does not filter remotely deleted credentials', async () => { + // ARRANGE + const user = mock(); + const statuses = [ + mock({ + status: 'deleted', + location: 'remote', + type: 'credential', + }), + mock({ + status: 'created', + location: 'local', + type: 'workflow', + }), + ]; + jest.spyOn(sourceControlService, 'getStatus').mockResolvedValueOnce(statuses); + + // ACT + const result = await sourceControlService.pullWorkfolder(user, {}); + + // ASSERT + expect(result).toMatchObject({ statusCode: 409, statusResult: statuses }); + }); + + it('should throw an error if a file is given that is not in the workfolder', async () => { + await expect( + sourceControlService.pushWorkfolder({ + fileNames: [ + { + file: '/etc/passwd', + id: 'test', + name: 'secret-file', + type: 'file', + status: 'modified', + location: 'local', + conflict: false, + updatedAt: new Date().toISOString(), + pushed: false, + }, + ], + }), + ).rejects.toThrow('File path /etc/passwd is invalid'); + }); + }); + + describe('getStatus', () => { + it('conflict depends on the value of `direction`', async () => { + // ARRANGE + + // Define a credential that does only exist locally. + // Pulling this would delete it so it should be marked as a conflict. + // Pushing this is conflict free. + sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]); + sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([ + mock(), + ]); + + // Define a credential that does only exist locally. + // Pulling this would delete it so it should be marked as a conflict. + // Pushing this is conflict free. + sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]); + sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([ + mock(), + ]); + + // Define a variable that does only exist locally. + // Pulling this would delete it so it should be marked as a conflict. + // Pushing this is conflict free. + sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]); + sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([mock()]); + + // Define a tag that does only exist locally. + // Pulling this would delete it so it should be marked as a conflict. + // Pushing this is conflict free. + const tag = mock({ updatedAt: new Date() }); + tagRepository.find.mockResolvedValue([tag]); + sourceControlImportService.getRemoteTagsAndMappingsFromFile.mockResolvedValue({ + tags: [], + mappings: [], + }); + sourceControlImportService.getLocalTagsAndMappingsFromDb.mockResolvedValue({ + tags: [tag], + mappings: [], + }); + + // ACT + const pullResult = await sourceControlService.getStatus({ + direction: 'pull', + verbose: false, + preferLocalVersion: false, + }); + + const pushResult = await sourceControlService.getStatus({ + direction: 'push', + verbose: false, + preferLocalVersion: false, + }); + + // ASSERT + console.log(pullResult); + console.log(pushResult); + + if (!Array.isArray(pullResult)) { + fail('Expected pullResult to be an array.'); + } + if (!Array.isArray(pushResult)) { + fail('Expected pushResult to be an array.'); + } + + expect(pullResult).toHaveLength(4); + expect(pushResult).toHaveLength(4); + + expect(pullResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', true); + expect(pushResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', false); + + expect(pullResult.find((i) => i.type === 'credential')).toHaveProperty('conflict', true); + expect(pushResult.find((i) => i.type === 'credential')).toHaveProperty('conflict', false); + + expect(pullResult.find((i) => i.type === 'variables')).toHaveProperty('conflict', true); + expect(pushResult.find((i) => i.type === 'variables')).toHaveProperty('conflict', false); + + expect(pullResult.find((i) => i.type === 'tags')).toHaveProperty('conflict', true); + expect(pushResult.find((i) => i.type === 'tags')).toHaveProperty('conflict', false); + }); + }); }); diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 21640dad0e..3c416c0b4c 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -9,9 +9,11 @@ import { readFile as fsReadFile } from 'node:fs/promises'; import path from 'path'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; +import { CredentialsService } from '@/credentials/credentials.service'; import type { Project } from '@/databases/entities/project'; import { SharedCredentials } from '@/databases/entities/shared-credentials'; import type { TagEntity } from '@/databases/entities/tag-entity'; +import type { User } from '@/databases/entities/user'; import type { Variables } from '@/databases/entities/variables'; import type { WorkflowTagMapping } from '@/databases/entities/workflow-tag-mapping'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; @@ -25,7 +27,9 @@ import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow- import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { IWorkflowToImport } from '@/interfaces'; import { isUniqueConstraintError } from '@/response-helper'; +import { TagService } from '@/services/tag.service'; import { assertNever } from '@/utils'; +import { WorkflowService } from '@/workflows/workflow.service'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, @@ -62,6 +66,9 @@ export class SourceControlImportService { private readonly variablesRepository: VariablesRepository, private readonly workflowRepository: WorkflowRepository, private readonly workflowTagMappingRepository: WorkflowTagMappingRepository, + private readonly workflowService: WorkflowService, + private readonly credentialsService: CredentialsService, + private readonly tagService: TagService, instanceSettings: InstanceSettings, ) { this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); @@ -500,6 +507,30 @@ export class SourceControlImportService { return result; } + async deleteWorkflowsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) { + for (const candidate of candidates) { + await this.workflowService.delete(user, candidate.id); + } + } + + async deleteCredentialsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) { + for (const candidate of candidates) { + await this.credentialsService.delete(user, candidate.id); + } + } + + async deleteVariablesNotInWorkfolder(candidates: SourceControlledFile[]) { + for (const candidate of candidates) { + await this.variablesService.delete(candidate.id); + } + } + + async deleteTagsNotInWorkfolder(candidates: SourceControlledFile[]) { + for (const candidate of candidates) { + await this.tagService.delete(candidate.id); + } + } + private async findOrCreateOwnerProject(owner: ResourceOwner): Promise { if (typeof owner === 'string' || owner.type === 'personal') { const email = typeof owner === 'string' ? owner : owner.personalEmail; diff --git a/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts index a7dd00d199..1f243a1447 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts @@ -191,7 +191,7 @@ export class SourceControlController { @Body payload: PullWorkFolderRequestDto, ): Promise { try { - const result = await this.sourceControlService.pullWorkfolder(req.user.id, payload); + const result = await this.sourceControlService.pullWorkfolder(req.user, payload); res.statusCode = result.statusCode; return result.statusResult; } catch (error) { diff --git a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index 3b330fffa3..2bd040ee3a 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -322,8 +322,44 @@ export class SourceControlService { }; } + private getConflicts(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((file) => file.conflict || file.status === 'modified'); + } + + private getWorkflowsToImport(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'workflow' && e.status !== 'deleted'); + } + + private getWorkflowsToDelete(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'workflow' && e.status === 'deleted'); + } + + private getCredentialsToImport(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'credential' && e.status !== 'deleted'); + } + + private getCredentialsToDelete(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'credential' && e.status === 'deleted'); + } + + private getTagsToImport(files: SourceControlledFile[]): SourceControlledFile | undefined { + return files.find((e) => e.type === 'tags' && e.status !== 'deleted'); + } + + private getTagsToDelete(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'tags' && e.status === 'deleted'); + } + + private getVariablesToImport(files: SourceControlledFile[]): SourceControlledFile | undefined { + return files.find((e) => e.type === 'variables' && e.status !== 'deleted'); + } + + private getVariablesToDelete(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'variables' && e.status === 'deleted'); + } + async pullWorkfolder( - userId: User['id'], + user: User, options: PullWorkFolderRequestDto, ): Promise<{ statusCode: number; statusResult: SourceControlledFile[] }> { await this.sanityCheck(); @@ -334,58 +370,51 @@ export class SourceControlService { preferLocalVersion: false, })) as SourceControlledFile[]; - // filter out items that will not effect a local change and thus should not - // trigger a conflict warning in the frontend - const filteredResult = statusResult.filter((e) => { - // locally created credentials will not create a conflict on pull - if (e.status === 'created' && e.location === 'local') { - return false; - } - // remotely deleted credentials will not delete local credentials - if (e.type === 'credential' && e.status === 'deleted') { - return false; - } - return true; - }); - - if (!options.force) { - const possibleConflicts = filteredResult?.filter( - (file) => (file.conflict || file.status === 'modified') && file.type === 'workflow', - ); + if (options.force !== true) { + const possibleConflicts = this.getConflicts(statusResult); if (possibleConflicts?.length > 0) { await this.gitService.resetBranch(); return { statusCode: 409, - statusResult: filteredResult, + statusResult, }; } } - const workflowsToBeImported = statusResult.filter( - (e) => e.type === 'workflow' && e.status !== 'deleted', - ); + const workflowsToBeImported = this.getWorkflowsToImport(statusResult); await this.sourceControlImportService.importWorkflowFromWorkFolder( workflowsToBeImported, - userId, + user.id, ); - - const credentialsToBeImported = statusResult.filter( - (e) => e.type === 'credential' && e.status !== 'deleted', + const workflowsToBeDeleted = this.getWorkflowsToDelete(statusResult); + await this.sourceControlImportService.deleteWorkflowsNotInWorkfolder( + user, + workflowsToBeDeleted, ); + const credentialsToBeImported = this.getCredentialsToImport(statusResult); await this.sourceControlImportService.importCredentialsFromWorkFolder( credentialsToBeImported, - userId, + user.id, + ); + const credentialsToBeDeleted = this.getCredentialsToDelete(statusResult); + await this.sourceControlImportService.deleteCredentialsNotInWorkfolder( + user, + credentialsToBeDeleted, ); - const tagsToBeImported = statusResult.find((e) => e.type === 'tags'); + const tagsToBeImported = this.getTagsToImport(statusResult); if (tagsToBeImported) { await this.sourceControlImportService.importTagsFromWorkFolder(tagsToBeImported); } + const tagsToBeDeleted = this.getTagsToDelete(statusResult); + await this.sourceControlImportService.deleteTagsNotInWorkfolder(tagsToBeDeleted); - const variablesToBeImported = statusResult.find((e) => e.type === 'variables'); + const variablesToBeImported = this.getVariablesToImport(statusResult); if (variablesToBeImported) { await this.sourceControlImportService.importVariablesFromWorkFolder(variablesToBeImported); } + const variablesToBeDeleted = this.getVariablesToDelete(statusResult); + await this.sourceControlImportService.deleteVariablesNotInWorkfolder(variablesToBeDeleted); // #region Tracking Information this.eventService.emit( @@ -396,7 +425,7 @@ export class SourceControlService { return { statusCode: 200, - statusResult: filteredResult, + statusResult, }; } @@ -536,7 +565,7 @@ export class SourceControlService { type: 'workflow', status: options.direction === 'push' ? 'created' : 'deleted', location: options.direction === 'push' ? 'local' : 'remote', - conflict: false, + conflict: options.direction === 'push' ? false : true, file: item.filename, updatedAt: item.updatedAt ?? new Date().toISOString(), }); @@ -617,7 +646,7 @@ export class SourceControlService { type: 'credential', status: options.direction === 'push' ? 'created' : 'deleted', location: options.direction === 'push' ? 'local' : 'remote', - conflict: false, + conflict: options.direction === 'push' ? false : true, file: item.filename, updatedAt: new Date().toISOString(), }); @@ -669,26 +698,47 @@ export class SourceControlService { } }); - if ( - varMissingInLocal.length > 0 || - varMissingInRemote.length > 0 || - varModifiedInEither.length > 0 - ) { - if (options.direction === 'pull' && varRemoteIds.length === 0) { - // if there's nothing to pull, don't show difference as modified - } else { - sourceControlledFiles.push({ - id: 'variables', - name: 'variables', - type: 'variables', - status: 'modified', - location: options.direction === 'push' ? 'local' : 'remote', - conflict: false, - file: getVariablesPath(this.gitFolder), - updatedAt: new Date().toISOString(), - }); - } - } + varMissingInLocal.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.key, + type: 'variables', + status: options.direction === 'push' ? 'deleted' : 'created', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: getVariablesPath(this.gitFolder), + updatedAt: new Date().toISOString(), + }); + }); + + varMissingInRemote.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.key, + type: 'variables', + status: options.direction === 'push' ? 'created' : 'deleted', + location: options.direction === 'push' ? 'local' : 'remote', + // if the we pull and the file is missing in the remote, we will delete + // it locally, which is communicated by marking this as a conflict + conflict: options.direction === 'push' ? false : true, + file: getVariablesPath(this.gitFolder), + updatedAt: new Date().toISOString(), + }); + }); + + varModifiedInEither.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.key, + type: 'variables', + status: 'modified', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: true, + file: getVariablesPath(this.gitFolder), + updatedAt: new Date().toISOString(), + }); + }); + return { varMissingInLocal, varMissingInRemote, @@ -743,32 +793,44 @@ export class SourceControlService { ) === -1, ); - if ( - tagsMissingInLocal.length > 0 || - tagsMissingInRemote.length > 0 || - tagsModifiedInEither.length > 0 || - mappingsMissingInLocal.length > 0 || - mappingsMissingInRemote.length > 0 - ) { - if ( - options.direction === 'pull' && - tagMappingsRemote.tags.length === 0 && - tagMappingsRemote.mappings.length === 0 - ) { - // if there's nothing to pull, don't show difference as modified - } else { - sourceControlledFiles.push({ - id: 'mappings', - name: 'tags', - type: 'tags', - status: 'modified', - location: options.direction === 'push' ? 'local' : 'remote', - conflict: false, - file: getTagsPath(this.gitFolder), - updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), - }); - } - } + tagsMissingInLocal.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name, + type: 'tags', + status: options.direction === 'push' ? 'deleted' : 'created', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: getTagsPath(this.gitFolder), + updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), + }); + }); + tagsMissingInRemote.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name, + type: 'tags', + status: options.direction === 'push' ? 'created' : 'deleted', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: options.direction === 'push' ? false : true, + file: getTagsPath(this.gitFolder), + updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), + }); + }); + + tagsModifiedInEither.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name, + type: 'tags', + status: 'modified', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: true, + file: getTagsPath(this.gitFolder), + updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), + }); + }); + return { tagsMissingInLocal, tagsMissingInRemote, diff --git a/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts b/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts index 09f5a1bc85..4a0729ef6e 100644 --- a/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts @@ -36,7 +36,7 @@ export = { try { const payload = PullWorkFolderRequestDto.parse(req.body); const sourceControlService = Container.get(SourceControlService); - const result = await sourceControlService.pullWorkfolder(req.user.id, payload); + const result = await sourceControlService.pullWorkfolder(req.user, payload); if (result.statusCode === 200) { Container.get(EventService).emit('source-control-user-pulled-api', { diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 43605939a3..9eb3f8efa1 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -130,7 +130,7 @@ export class ProjectService { ); } else { for (const sharedCredential of ownedCredentials) { - await credentialsService.delete(sharedCredential.credentials); + await credentialsService.delete(user, sharedCredential.credentials.id); } } diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 6460348b23..fdb53c1832 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -272,6 +272,13 @@ export class WorkflowService { return updatedWorkflow; } + /** + * Deletes a workflow and returns it. + * + * If the workflow is active this will deactivate the workflow. + * If the user does not have the permissions to delete the workflow this does + * nothing and returns void. + */ async delete(user: User, workflowId: string): Promise { await this.externalHooks.run('workflow.delete', [workflowId]); diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index 8b774f8fa9..5bfd5d0e79 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -50,6 +50,9 @@ describe('SourceControlImportService', () => { mock(), mock(), mock(), + mock(), + mock(), + mock(), mock({ n8nFolder: '/some-path' }), ); }); diff --git a/packages/editor-ui/src/components/MainSidebarSourceControl.test.ts b/packages/editor-ui/src/components/MainSidebarSourceControl.test.ts index dc3e805b05..15ed1a103c 100644 --- a/packages/editor-ui/src/components/MainSidebarSourceControl.test.ts +++ b/packages/editor-ui/src/components/MainSidebarSourceControl.test.ts @@ -166,95 +166,5 @@ describe('MainSidebarSourceControl', () => { ), ); }); - - it("should show user's feedback when pulling", async () => { - vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([ - { - id: '014da93897f146d2b880-baa374b9d02d', - name: 'vuelfow2', - type: 'workflow', - status: 'created', - location: 'remote', - conflict: false, - file: '/014da93897f146d2b880-baa374b9d02d.json', - updatedAt: '2025-01-09T13:12:24.580Z', - }, - { - id: 'a102c0b9-28ac-43cb-950e-195723a56d54', - name: 'Gmail account', - type: 'credential', - status: 'created', - location: 'remote', - conflict: false, - file: '/a102c0b9-28ac-43cb-950e-195723a56d54.json', - updatedAt: '2025-01-09T13:12:24.586Z', - }, - { - id: 'variables', - name: 'variables', - type: 'variables', - status: 'modified', - location: 'remote', - conflict: false, - file: '/variable_stubs.json', - updatedAt: '2025-01-09T13:12:24.588Z', - }, - { - id: 'mappings', - name: 'tags', - type: 'tags', - status: 'modified', - location: 'remote', - conflict: false, - file: '/tags.json', - updatedAt: '2024-12-16T12:53:12.155Z', - }, - ]); - - const { getAllByRole } = renderComponent({ - pinia, - props: { isCollapsed: false }, - }); - - await userEvent.click(getAllByRole('button')[0]); - await waitFor(() => { - expect(showToast).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - title: 'Finish setting up your new variables to use in workflows', - }), - ); - expect(showToast).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - title: 'Finish setting up your new credentials to use in workflows', - }), - ); - expect(showToast).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - message: '1 Workflow, 1 Credential, Variables, and Tags were pulled', - }), - ); - }); - }); - - it('should show feedback where there are no change to pull', async () => { - vi.spyOn(sourceControlStore, 'pullWorkfolder').mockResolvedValueOnce([]); - - const { getAllByRole } = renderComponent({ - pinia, - props: { isCollapsed: false }, - }); - - await userEvent.click(getAllByRole('button')[0]); - await waitFor(() => { - expect(showMessage).toHaveBeenCalledWith( - expect.objectContaining({ - title: 'Up to date', - }), - ); - }); - }); }); }); diff --git a/packages/editor-ui/src/components/MainSidebarSourceControl.vue b/packages/editor-ui/src/components/MainSidebarSourceControl.vue index af861e93c9..32c1638f96 100644 --- a/packages/editor-ui/src/components/MainSidebarSourceControl.vue +++ b/packages/editor-ui/src/components/MainSidebarSourceControl.vue @@ -1,5 +1,5 @@