feat(core): Scope getStatus for environments for project admin role (#15404)

This commit is contained in:
Andreas Fitzek
2025-05-22 13:49:54 +02:00
committed by GitHub
parent 8152f8c6a7
commit f9f9597bbd
11 changed files with 2275 additions and 66 deletions

View File

@@ -1,4 +1,4 @@
import type { WorkflowEntity } from '@n8n/db';
import { Project, type ProjectRepository, User, WorkflowEntity } from '@n8n/db';
import type { FolderRepository } from '@n8n/db';
import type { WorkflowRepository } from '@n8n/db';
import * as fastGlob from 'fast-glob';
@@ -7,20 +7,36 @@ import { type InstanceSettings } from 'n8n-core';
import fsp from 'node:fs/promises';
import { SourceControlImportService } from '../source-control-import.service.ee';
import type { SourceControlScopedService } from '../source-control-scoped.service';
import type { ExportableFolder } from '../types/exportable-folders';
import { SourceControlContext } from '../types/source-control-context';
jest.mock('fast-glob');
const globalAdminContext = new SourceControlContext(
Object.assign(new User(), {
role: 'global:admin',
}),
);
const globalMemberContext = new SourceControlContext(
Object.assign(new User(), {
role: 'global:member',
}),
);
describe('SourceControlImportService', () => {
const workflowRepository = mock<WorkflowRepository>();
const folderRepository = mock<FolderRepository>();
const projectRepository = mock<ProjectRepository>();
const sourceControlScopedService = mock<SourceControlScopedService>();
const service = new SourceControlImportService(
mock(),
mock(),
mock(),
mock(),
mock(),
mock(),
projectRepository,
mock(),
mock(),
mock(),
@@ -33,6 +49,7 @@ describe('SourceControlImportService', () => {
mock(),
folderRepository,
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
sourceControlScopedService,
);
const globMock = fastGlob.default as unknown as jest.Mock<Promise<string[]>, string[]>;
@@ -53,7 +70,7 @@ describe('SourceControlImportService', () => {
fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData));
const result = await service.getRemoteVersionIdsFromFiles();
const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext);
expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' });
expect(result).toHaveLength(1);
@@ -71,7 +88,7 @@ describe('SourceControlImportService', () => {
fsReadFile.mockResolvedValue('{}');
const result = await service.getRemoteVersionIdsFromFiles();
const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext);
expect(result).toHaveLength(0);
});
@@ -89,7 +106,7 @@ describe('SourceControlImportService', () => {
fsReadFile.mockResolvedValue(JSON.stringify(mockCredentialData));
const result = await service.getRemoteCredentialsFromFiles();
const result = await service.getRemoteCredentialsFromFiles(globalAdminContext);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
@@ -105,7 +122,7 @@ describe('SourceControlImportService', () => {
globMock.mockResolvedValue(['/mock/invalid.json']);
fsReadFile.mockResolvedValue('{}');
const result = await service.getRemoteCredentialsFromFiles();
const result = await service.getRemoteCredentialsFromFiles(globalAdminContext);
expect(result).toHaveLength(0);
});
@@ -147,7 +164,7 @@ describe('SourceControlImportService', () => {
fsReadFile.mockResolvedValue(JSON.stringify(mockTagsData));
const result = await service.getRemoteTagsAndMappingsFromFile();
const result = await service.getRemoteTagsAndMappingsFromFile(globalAdminContext);
expect(result.tags).toEqual(mockTagsData.tags);
expect(result.mappings).toEqual(mockTagsData.mappings);
@@ -156,11 +173,39 @@ describe('SourceControlImportService', () => {
it('should return empty tags and mappings if no file found', async () => {
globMock.mockResolvedValue([]);
const result = await service.getRemoteTagsAndMappingsFromFile();
const result = await service.getRemoteTagsAndMappingsFromFile(globalAdminContext);
expect(result.tags).toHaveLength(0);
expect(result.mappings).toHaveLength(0);
});
it('should return only folder that belong to a project that belongs to the user', async () => {
globMock.mockResolvedValue(['/mock/tags.json']);
const mockTagsData = {
tags: [{ id: 'tag1', name: 'Tag 1' }],
mappings: [
{ workflowId: 'workflow1', tagId: 'tag1' },
{ workflowId: 'workflow2', tagId: 'tag1' },
{ workflowId: 'workflow3', tagId: 'tag1' },
],
};
workflowRepository.find.mockResolvedValue([
Object.assign(new WorkflowEntity(), {
id: 'workflow1',
}),
Object.assign(new WorkflowEntity(), {
id: 'workflow3',
}),
]);
fsReadFile.mockResolvedValue(JSON.stringify(mockTagsData));
const result = await service.getRemoteTagsAndMappingsFromFile(globalAdminContext);
expect(result.tags).toEqual(mockTagsData.tags);
expect(result.mappings).toEqual(mockTagsData.mappings);
});
});
describe('getRemoteFoldersAndMappingsFromFile', () => {
@@ -186,7 +231,7 @@ describe('SourceControlImportService', () => {
fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData));
const result = await service.getRemoteFoldersAndMappingsFromFile();
const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext);
expect(result.folders).toEqual(mockFoldersData.folders);
});
@@ -194,10 +239,81 @@ describe('SourceControlImportService', () => {
it('should return empty folders and mappings if no file found', async () => {
globMock.mockResolvedValue([]);
const result = await service.getRemoteFoldersAndMappingsFromFile();
const result = await service.getRemoteFoldersAndMappingsFromFile(globalAdminContext);
expect(result.folders).toHaveLength(0);
});
it('should return only folder that belong to a project that belongs to the user', async () => {
globMock.mockResolvedValue(['/mock/folders.json']);
const now = new Date();
const foldersToFind: ExportableFolder[] = [
{
id: 'folder1',
name: 'folder 1',
parentFolderId: null,
homeProjectId: 'project1',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
{
id: 'folder3',
name: 'folder 3',
parentFolderId: null,
homeProjectId: 'project1',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
{
id: 'folder4',
name: 'folder 3',
parentFolderId: null,
homeProjectId: 'project3',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
];
const mockFoldersData: {
folders: ExportableFolder[];
} = {
folders: [
{
id: 'folder0',
name: 'folder 0',
parentFolderId: null,
homeProjectId: 'project0',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
...foldersToFind,
{
id: 'folder2',
name: 'folder 2',
parentFolderId: null,
homeProjectId: 'project2',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
},
],
};
sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValue([
Object.assign(new Project(), {
id: 'project1',
}),
Object.assign(new Project(), {
id: 'project3',
}),
]);
fsReadFile.mockResolvedValue(JSON.stringify(mockFoldersData));
const result = await service.getRemoteFoldersAndMappingsFromFile(globalMemberContext);
expect(result.folders).toEqual(foldersToFind);
});
});
describe('getLocalVersionIdsFromDb', () => {
@@ -215,7 +331,7 @@ describe('SourceControlImportService', () => {
workflowRepository.find.mockResolvedValue(mockWorkflows);
const result = await service.getLocalVersionIdsFromDb();
const result = await service.getLocalVersionIdsFromDb(globalAdminContext);
expect(result[0].updatedAt).toBe(now.toISOString());
});
@@ -232,7 +348,7 @@ describe('SourceControlImportService', () => {
// Act
const result = await service.getLocalFoldersAndMappingsFromDb();
const result = await service.getLocalFoldersAndMappingsFromDb(globalAdminContext);
// Assert

View File

@@ -141,6 +141,7 @@ describe('SourceControlService', () => {
it('conflict depends on the value of `direction`', async () => {
// ARRANGE
const user = mock<User>();
user.role = 'global:admin';
// Define a credential that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.

View File

@@ -39,9 +39,12 @@ import {
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
} from './constants';
import { getCredentialExportPath, getWorkflowExportPath } from './source-control-helper.ee';
import { SourceControlScopedService } from './source-control-scoped.service';
import type { ExportableCredential } from './types/exportable-credential';
import type { ExportableFolder } from './types/exportable-folders';
import type { ExportableTags } from './types/exportable-tags';
import type { ResourceOwner } from './types/resource-owner';
import type { SourceControlContext } from './types/source-control-context';
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
import { VariablesService } from '../variables/variables.service.ee';
@@ -72,6 +75,7 @@ export class SourceControlImportService {
private readonly tagService: TagService,
private readonly folderRepository: FolderRepository,
instanceSettings: InstanceSettings,
private readonly sourceControlScopedService: SourceControlScopedService,
) {
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER);
@@ -81,19 +85,41 @@ export class SourceControlImportService {
);
}
async getRemoteVersionIdsFromFiles(): Promise<SourceControlWorkflowVersionId[]> {
async getRemoteVersionIdsFromFiles(
context: SourceControlContext,
): Promise<SourceControlWorkflowVersionId[]> {
const remoteWorkflowFiles = await glob('*.json', {
cwd: this.workflowExportFolder,
absolute: true,
});
const remoteWorkflowFilesParsed = await Promise.all(
const accessibleProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
const remoteWorkflowsRead = await Promise.all(
remoteWorkflowFiles.map(async (file) => {
this.logger.debug(`Parsing workflow file ${file}`);
const remote = jsonParse<IWorkflowToImport>(await fsReadFile(file, { encoding: 'utf8' }));
return jsonParse<IWorkflowToImport>(await fsReadFile(file, { encoding: 'utf8' }));
}),
);
const remoteWorkflowFilesParsed = remoteWorkflowsRead
.filter((remote) => {
if (!remote?.id) {
return undefined;
return false;
}
if (Array.isArray(accessibleProjects)) {
const owner = remote.owner;
// The workflow `remote` belongs not to a project, that the context has access to
return (
typeof owner === 'object' &&
owner?.type === 'team' &&
accessibleProjects.some((project) => project.id === owner.teamId)
);
}
return true;
})
.map((remote) => {
return {
id: remote.id,
versionId: remote.versionId,
@@ -102,14 +128,12 @@ export class SourceControlImportService {
remoteId: remote.id,
filename: getWorkflowExportPath(remote.id, this.workflowExportFolder),
} as SourceControlWorkflowVersionId;
}),
);
return remoteWorkflowFilesParsed.filter(
(e): e is SourceControlWorkflowVersionId => e !== undefined,
);
});
return remoteWorkflowFilesParsed;
}
async getLocalVersionIdsFromDb(): Promise<SourceControlWorkflowVersionId[]> {
async getAllLocalVersionIdsFromDb(): Promise<SourceControlWorkflowVersionId[]> {
const localWorkflows = await this.workflowRepository.find({
relations: ['parentFolder'],
select: {
@@ -147,36 +171,105 @@ export class SourceControlImportService {
}) as SourceControlWorkflowVersionId[];
}
async getRemoteCredentialsFromFiles(): Promise<
Array<ExportableCredential & { filename: string }>
> {
async getLocalVersionIdsFromDb(
context: SourceControlContext,
): Promise<SourceControlWorkflowVersionId[]> {
const localWorkflows = await this.workflowRepository.find({
relations: {
parentFolder: true,
},
select: {
id: true,
versionId: true,
name: true,
updatedAt: true,
parentFolder: {
id: true,
},
},
where: this.sourceControlScopedService.getWorkflowsInAdminProjectsFromContextFilter(context),
});
return localWorkflows.map((local) => {
let updatedAt: Date;
if (local.updatedAt instanceof Date) {
updatedAt = local.updatedAt;
} else {
this.errorReporter.warn('updatedAt is not a Date', {
extra: {
type: typeof local.updatedAt,
value: local.updatedAt,
},
});
updatedAt = isNaN(Date.parse(local.updatedAt)) ? new Date() : new Date(local.updatedAt);
}
return {
id: local.id,
versionId: local.versionId,
name: local.name,
localId: local.id,
parentFolderId: local.parentFolder?.id ?? null,
filename: getWorkflowExportPath(local.id, this.workflowExportFolder),
updatedAt: updatedAt.toISOString(),
};
}) as SourceControlWorkflowVersionId[];
}
async getRemoteCredentialsFromFiles(
context: SourceControlContext,
): Promise<Array<ExportableCredential & { filename: string }>> {
const remoteCredentialFiles = await glob('*.json', {
cwd: this.credentialExportFolder,
absolute: true,
});
const remoteCredentialFilesParsed = await Promise.all(
const accessibleProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
const remoteCredentialFilesRead = await Promise.all(
remoteCredentialFiles.map(async (file) => {
this.logger.debug(`Parsing credential file ${file}`);
const remote = jsonParse<ExportableCredential>(
await fsReadFile(file, { encoding: 'utf8' }),
);
return remote;
}),
);
const remoteCredentialFilesParsed = remoteCredentialFilesRead
.filter((remote) => {
if (!remote?.id) {
return undefined;
return false;
}
if (Array.isArray(accessibleProjects)) {
const owner = remote.ownedBy;
// The credential `remote` belongs not to a project, that the context has access to
return (
typeof owner === 'object' &&
owner?.type === 'team' &&
accessibleProjects.some((project) => project.id === owner.teamId)
);
}
return true;
})
.map((remote) => {
return {
...remote,
filename: getCredentialExportPath(remote.id, this.credentialExportFolder),
};
}),
);
});
return remoteCredentialFilesParsed.filter((e) => e !== undefined) as Array<
ExportableCredential & { filename: string }
>;
}
async getLocalCredentialsFromDb(): Promise<Array<ExportableCredential & { filename: string }>> {
async getLocalCredentialsFromDb(
context: SourceControlContext,
): Promise<Array<ExportableCredential & { filename: string }>> {
const localCredentials = await this.credentialsRepository.find({
select: ['id', 'name', 'type'],
where:
this.sourceControlScopedService.getCredentialsInAdminProjectsFromContextFilter(context),
});
return localCredentials.map((local) => ({
id: local.id,
@@ -204,7 +297,7 @@ export class SourceControlImportService {
return await this.variablesService.getAllCached();
}
async getRemoteFoldersAndMappingsFromFile(): Promise<{
async getRemoteFoldersAndMappingsFromFile(context: SourceControlContext): Promise<{
folders: ExportableFolder[];
}> {
const foldersFile = await glob(SOURCE_CONTROL_FOLDERS_EXPORT_FILE, {
@@ -218,12 +311,22 @@ export class SourceControlImportService {
}>(await fsReadFile(foldersFile[0], { encoding: 'utf8' }), {
fallbackValue: { folders: [] },
});
const accessibleProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
if (Array.isArray(accessibleProjects)) {
mappedFolders.folders = mappedFolders.folders.filter((folder) =>
accessibleProjects.some((project) => project.id === folder.homeProjectId),
);
}
return mappedFolders;
}
return { folders: [] };
}
async getLocalFoldersAndMappingsFromDb(): Promise<{
async getLocalFoldersAndMappingsFromDb(context: SourceControlContext): Promise<{
folders: ExportableFolder[];
}> {
const localFolders = await this.folderRepository.find({
@@ -236,6 +339,7 @@ export class SourceControlImportService {
parentFolder: { id: true },
homeProject: { id: true },
},
where: this.sourceControlScopedService.getFoldersInAdminProjectsFromContextFilter(context),
});
return {
@@ -250,26 +354,33 @@ export class SourceControlImportService {
};
}
async getRemoteTagsAndMappingsFromFile(): Promise<{
tags: TagEntity[];
mappings: WorkflowTagMapping[];
}> {
async getRemoteTagsAndMappingsFromFile(context: SourceControlContext): Promise<ExportableTags> {
const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
});
if (tagsFile.length > 0) {
this.logger.debug(`Importing tags from file ${tagsFile[0]}`);
const mappedTags = jsonParse<{ tags: TagEntity[]; mappings: WorkflowTagMapping[] }>(
const mappedTags = jsonParse<ExportableTags>(
await fsReadFile(tagsFile[0], { encoding: 'utf8' }),
{ fallbackValue: { tags: [], mappings: [] } },
);
const accessibleWorkflows =
await this.sourceControlScopedService.getWorkflowsInAdminProjectsFromContext(context);
if (accessibleWorkflows) {
mappedTags.mappings = mappedTags.mappings.filter((mapping) =>
accessibleWorkflows.some((workflow) => workflow.id === mapping.workflowId),
);
}
return mappedTags;
}
return { tags: [], mappings: [] };
}
async getLocalTagsAndMappingsFromDb(): Promise<{
async getLocalTagsAndMappingsFromDb(context: SourceControlContext): Promise<{
tags: TagEntity[];
mappings: WorkflowTagMapping[];
}> {
@@ -278,6 +389,10 @@ export class SourceControlImportService {
});
const localMappings = await this.workflowTagMappingRepository.find({
select: ['workflowId', 'tagId'],
where:
this.sourceControlScopedService.getWorkflowTagMappingInAdminProjectsFromContextFilter(
context,
),
});
return { tags: localTags, mappings: localMappings };
}

View File

@@ -0,0 +1,139 @@
import {
type CredentialsEntity,
type Folder,
type Project,
ProjectRepository,
type WorkflowEntity,
WorkflowRepository,
type WorkflowTagMapping,
} from '@n8n/db';
import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { FindOptionsWhere } from '@n8n/typeorm';
import type { SourceControlContext } from './types/source-control-context';
@Service()
export class SourceControlScopedService {
constructor(
private readonly projectRepository: ProjectRepository,
private readonly workflowRepository: WorkflowRepository,
) {}
async getAdminProjectsFromContext(context: SourceControlContext): Promise<Project[] | undefined> {
if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter
return;
}
return await this.projectRepository.find({
relations: {
projectRelations: true,
},
select: {
id: true,
name: true,
},
where: this.getAdminProjectsByContextFilter(context),
});
}
async getWorkflowsInAdminProjectsFromContext(
context: SourceControlContext,
): Promise<WorkflowEntity[] | undefined> {
if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter
return;
}
return await this.workflowRepository.find({
select: {
id: true,
},
where: this.getWorkflowsInAdminProjectsFromContextFilter(context),
});
}
getAdminProjectsByContextFilter(
context: SourceControlContext,
): FindOptionsWhere<Project> | undefined {
if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter
return;
}
return {
type: 'team',
projectRelations: {
role: 'project:admin',
userId: context.user.id,
},
};
}
getFoldersInAdminProjectsFromContextFilter(
context: SourceControlContext,
): FindOptionsWhere<Folder> | undefined {
if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter
return;
}
// We build a filter to only select folder, that belong to a team project
// that the user is an admin off
return {
homeProject: this.getAdminProjectsByContextFilter(context),
};
}
getWorkflowsInAdminProjectsFromContextFilter(
context: SourceControlContext,
): FindOptionsWhere<WorkflowEntity> | undefined {
if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter
return;
}
// We build a filter to only select workflows, that belong to a team project
// that the user is an admin off
return {
shared: {
role: 'workflow:owner',
project: this.getAdminProjectsByContextFilter(context),
},
};
}
getCredentialsInAdminProjectsFromContextFilter(
context: SourceControlContext,
): FindOptionsWhere<CredentialsEntity> | undefined {
if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter
return;
}
// We build a filter to only select workflows, that belong to a team project
// that the user is an admin off
return {
shared: {
role: 'credential:owner',
project: this.getAdminProjectsByContextFilter(context),
},
};
}
getWorkflowTagMappingInAdminProjectsFromContextFilter(
context: SourceControlContext,
): FindOptionsWhere<WorkflowTagMapping> | undefined {
if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter
return;
}
// We build a filter to only select workflows, that belong to a team project
// that the user is an admin off
return {
workflows: this.getWorkflowsInAdminProjectsFromContextFilter(context),
};
}
}

View File

@@ -3,9 +3,15 @@ import type {
PushWorkFolderRequestDto,
SourceControlledFile,
} from '@n8n/api-types';
import type { Variables, TagEntity, User } from '@n8n/db';
import { FolderRepository, TagRepository } from '@n8n/db';
import {
type Variables,
type TagEntity,
FolderRepository,
TagRepository,
type User,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import { writeFileSync } from 'fs';
import { Logger } from 'n8n-core';
import { UnexpectedError, UserError } from 'n8n-workflow';
@@ -13,6 +19,7 @@ import path from 'path';
import type { PushResult } from 'simple-git';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { EventService } from '@/events/event.service';
import {
@@ -38,6 +45,7 @@ import { SourceControlPreferencesService } from './source-control-preferences.se
import type { ExportableCredential } from './types/exportable-credential';
import type { ExportableFolder } from './types/exportable-folders';
import type { ImportResult } from './types/import-result';
import { SourceControlContext } from './types/source-control-context';
import type { SourceControlGetStatus } from './types/source-control-get-status';
import type { SourceControlPreferences } from './types/source-control-preferences';
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
@@ -481,6 +489,13 @@ export class SourceControlService {
async getStatus(user: User, options: SourceControlGetStatus) {
await this.sanityCheck();
const context = new SourceControlContext(user);
if (options.direction === 'pull' && !hasGlobalScope(user, 'sourceControl:pull')) {
// A pull is only allowed by global admins or owners
return new ForbiddenError('You do not have permission to pull from source control');
}
const sourceControlledFiles: SourceControlledFile[] = [];
// fetch and reset hard first
@@ -492,10 +507,10 @@ export class SourceControlService {
wfMissingInLocal,
wfMissingInRemote,
wfModifiedInEither,
} = await this.getStatusWorkflows(options, sourceControlledFiles);
} = await this.getStatusWorkflows(options, context, sourceControlledFiles);
const { credMissingInLocal, credMissingInRemote, credModifiedInEither } =
await this.getStatusCredentials(options, sourceControlledFiles);
await this.getStatusCredentials(options, context, sourceControlledFiles);
const { varMissingInLocal, varMissingInRemote, varModifiedInEither } =
await this.getStatusVariables(options, sourceControlledFiles);
@@ -506,10 +521,10 @@ export class SourceControlService {
tagsModifiedInEither,
mappingsMissingInLocal,
mappingsMissingInRemote,
} = await this.getStatusTagsMappings(options, sourceControlledFiles);
} = await this.getStatusTagsMappings(options, context, sourceControlledFiles);
const { foldersMissingInLocal, foldersMissingInRemote, foldersModifiedInEither } =
await this.getStatusFoldersMapping(options, sourceControlledFiles);
await this.getStatusFoldersMapping(options, context, sourceControlledFiles);
// #region Tracking Information
if (options.direction === 'push') {
@@ -555,14 +570,34 @@ export class SourceControlService {
private async getStatusWorkflows(
options: SourceControlGetStatus,
context: SourceControlContext,
sourceControlledFiles: SourceControlledFile[],
) {
const wfRemoteVersionIds = await this.sourceControlImportService.getRemoteVersionIdsFromFiles();
const wfLocalVersionIds = await this.sourceControlImportService.getLocalVersionIdsFromDb();
// TODO: We need to check the case where it exists in the DB (out of scope) but is in GIT
const wfRemoteVersionIds =
await this.sourceControlImportService.getRemoteVersionIdsFromFiles(context);
const wfLocalVersionIds =
await this.sourceControlImportService.getLocalVersionIdsFromDb(context);
const wfMissingInLocal = wfRemoteVersionIds.filter(
(remote) => wfLocalVersionIds.findIndex((local) => local.id === remote.id) === -1,
);
let outOfScopeWF: SourceControlWorkflowVersionId[] = [];
if (!context.hasAccessToAllProjects()) {
// we need to query for all wf in the DB to hide possible deletions,
// when a wf went out of scope locally
outOfScopeWF = await this.sourceControlImportService.getAllLocalVersionIdsFromDb();
outOfScopeWF = outOfScopeWF.filter(
(wf) => !wfLocalVersionIds.some((local) => local.id === wf.id),
);
}
const wfMissingInLocal = wfRemoteVersionIds
.filter((remote) => wfLocalVersionIds.findIndex((local) => local.id === remote.id) === -1)
.filter(
// If we have out of scope workflows, these are workflows, that are not
// visible locally, but exists locally but are available in remote
// we skip them and hide them from deletion from the user.
(remote) => !outOfScopeWF.some((outOfScope) => outOfScope.id === remote.id),
);
const wfMissingInRemote = wfLocalVersionIds.filter(
(local) => wfRemoteVersionIds.findIndex((remote) => remote.id === local.id) === -1,
@@ -654,10 +689,12 @@ export class SourceControlService {
private async getStatusCredentials(
options: SourceControlGetStatus,
context: SourceControlContext,
sourceControlledFiles: SourceControlledFile[],
) {
const credRemoteIds = await this.sourceControlImportService.getRemoteCredentialsFromFiles();
const credLocalIds = await this.sourceControlImportService.getLocalCredentialsFromDb();
const credRemoteIds =
await this.sourceControlImportService.getRemoteCredentialsFromFiles(context);
const credLocalIds = await this.sourceControlImportService.getLocalCredentialsFromDb(context);
const credMissingInLocal = credRemoteIds.filter(
(remote) => credLocalIds.findIndex((local) => local.id === remote.id) === -1,
@@ -807,6 +844,7 @@ export class SourceControlService {
private async getStatusTagsMappings(
options: SourceControlGetStatus,
context: SourceControlContext,
sourceControlledFiles: SourceControlledFile[],
) {
const lastUpdatedTag = await this.tagRepository.find({
@@ -816,8 +854,9 @@ export class SourceControlService {
});
const tagMappingsRemote =
await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile();
const tagMappingsLocal = await this.sourceControlImportService.getLocalTagsAndMappingsFromDb();
await this.sourceControlImportService.getRemoteTagsAndMappingsFromFile(context);
const tagMappingsLocal =
await this.sourceControlImportService.getLocalTagsAndMappingsFromDb(context);
const tagsMissingInLocal = tagMappingsRemote.tags.filter(
(remote) => tagMappingsLocal.tags.findIndex((local) => local.id === remote.id) === -1,
@@ -901,6 +940,7 @@ export class SourceControlService {
private async getStatusFoldersMapping(
options: SourceControlGetStatus,
context: SourceControlContext,
sourceControlledFiles: SourceControlledFile[],
) {
const lastUpdatedFolder = await this.folderRepository.find({
@@ -910,9 +950,9 @@ export class SourceControlService {
});
const foldersMappingsRemote =
await this.sourceControlImportService.getRemoteFoldersAndMappingsFromFile();
await this.sourceControlImportService.getRemoteFoldersAndMappingsFromFile(context);
const foldersMappingsLocal =
await this.sourceControlImportService.getLocalFoldersAndMappingsFromDb();
await this.sourceControlImportService.getLocalFoldersAndMappingsFromDb(context);
const foldersMissingInLocal = foldersMappingsRemote.folders.filter(
(remote) => foldersMappingsLocal.folders.findIndex((local) => local.id === remote.id) === -1,

View File

@@ -0,0 +1,3 @@
import type { TagEntity, WorkflowTagMapping } from '@n8n/db';
export type ExportableTags = { tags: TagEntity[]; mappings: WorkflowTagMapping[] };

View File

@@ -0,0 +1,14 @@
import type { User } from '@n8n/db';
import { hasGlobalScope } from '@n8n/permissions';
export class SourceControlContext {
constructor(private readonly userInternal: User) {}
get user() {
return this.userInternal;
}
hasAccessToAllProjects() {
return hasGlobalScope(this.userInternal, 'project:update');
}
}

View File

@@ -48,10 +48,16 @@ export interface IWorkflowResponse extends IWorkflowBase {
export interface IWorkflowToImport
extends Omit<IWorkflowBase, 'staticData' | 'pinData' | 'createdAt' | 'updatedAt'> {
owner: {
type: 'personal';
personalEmail: string;
};
owner:
| {
type: 'personal';
personalEmail: string;
}
| {
type: 'team';
teamId: string;
teamName: string;
};
parentFolderId: string | null;
}

View File

@@ -13,6 +13,7 @@ import * as utils from '../shared/utils';
let authOwnerAgent: SuperAgentTest;
let owner: User;
mockInstance(Telemetry);
const testServer = utils.setupTestServer({

View File

@@ -0,0 +1,777 @@
import {
CredentialsEntity,
type Folder,
Project,
type TagEntity,
type User,
WorkflowEntity,
} from '@n8n/db';
import { Container } from '@n8n/di';
import * as fastGlob from 'fast-glob';
import fsp from 'node:fs/promises';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
} from '@/environments.ee/source-control/constants';
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 { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential';
import type { ExportableFolder } from '@/environments.ee/source-control/types/exportable-folders';
import type { ExportableWorkflow } from '@/environments.ee/source-control/types/exportable-workflow';
import type { ResourceOwner } from '@/environments.ee/source-control/types/resource-owner';
import { createCredentials } from '@test-integration/db/credentials';
import { createFolder } from '@test-integration/db/folders';
import { createTeamProject } from '@test-integration/db/projects';
import { assignTagToWorkflow, createTag } from '@test-integration/db/tags';
import { createUser } from '@test-integration/db/users';
import { createWorkflow } from '@test-integration/db/workflows';
import * as testDb from '../shared/test-db';
jest.mock('fast-glob');
type Scope = {
workflows: WorkflowEntity[];
credentials: CredentialsEntity[];
folders: Folder[];
};
let sourceControlPreferencesService: SourceControlPreferencesService;
function toExportableFolder(folder: Folder): ExportableFolder {
return {
id: folder.id,
name: folder.name,
homeProjectId: folder.homeProject.id,
parentFolderId: folder.parentFolderId,
createdAt: folder.createdAt.toISOString(),
updatedAt: folder.updatedAt.toISOString(),
};
}
function toExportableCredential(
cred: CredentialsEntity,
owner: Project | User,
): ExportableCredential {
let resourceOwner: ResourceOwner;
if (owner instanceof Project) {
resourceOwner = {
type: 'team',
teamId: owner.id,
teamName: owner.name,
};
} else {
resourceOwner = {
type: 'personal',
personalEmail: owner.email,
};
}
return {
id: cred.id,
data: {},
name: cred.name,
type: cred.type,
ownedBy: resourceOwner,
};
}
function toExportableWorkflow(
wf: WorkflowEntity,
owner: Project | User,
versionId?: string,
): ExportableWorkflow {
let resourceOwner: ResourceOwner;
if (owner instanceof Project) {
resourceOwner = {
type: 'team',
teamId: owner.id,
teamName: owner.name,
};
} else {
resourceOwner = {
type: 'personal',
personalEmail: owner.email,
};
}
return {
id: wf.id,
name: wf.name,
connections: wf.connections,
isArchived: wf.isArchived,
nodes: wf.nodes,
owner: resourceOwner,
triggerCount: wf.triggerCount,
parentFolderId: null,
versionId: versionId ?? wf.versionId,
};
}
describe('SourceControlService', () => {
beforeAll(async () => {
await testDb.init();
sourceControlPreferencesService = Container.get(SourceControlPreferencesService);
await sourceControlPreferencesService.setPreferences({
connected: true,
keyGeneratorType: 'rsa',
});
});
afterAll(async () => {
await testDb.terminate();
});
describe('getStatus', () => {
/*
Test scenarios (push):
1. globalAdmin
sees everything, workflows in different projects, credentials in different projects, tags and mappings in different projects, folders in different projects
2. globalOwner
same as global Admin
3. globalMember
sees nothing ...
4. projectAdmin (global member)
sees workflows in his team projects only, credentials in his team projects only, same for mappings and folders, sees all tags
5. projectMember
sees nothing
Test scenarios (pull):
TBD!
*/
let globalAdmin: User;
let globalOwner: User;
let globalMember: User;
let projectAdmin: User;
let projectA: Project;
let projectB: Project;
let globalAdminScope: Scope;
let globalOwnerScope: Scope;
let globalMemberScope: Scope;
let projectAdminScope: Scope;
let projectAScope: Scope;
let projectBScope: Scope;
let allWorkflows: WorkflowEntity[];
let tags: TagEntity[];
let gitFiles: Record<string, unknown>;
let movedOutOfScopeWorkflow: WorkflowEntity;
let movedIntoScopeWorkflow: WorkflowEntity;
let deletedOutOfScopeWorkflow: WorkflowEntity;
let deletedInScopeWorkflow: WorkflowEntity;
let movedOutOfScopeCredential: CredentialsEntity;
let movedIntoScopeCredential: CredentialsEntity;
let deletedOutOfScopeCredential: CredentialsEntity;
let deletedInScopeCredential: CredentialsEntity;
let service: SourceControlService;
const globMock = fastGlob.default as unknown as jest.Mock<
Promise<string[]>,
[fastGlob.Pattern | fastGlob.Pattern[], fastGlob.Options]
>;
const fsReadFile = jest.spyOn(fsp, 'readFile');
beforeAll(async () => {
/*
Set up test conditions:
4 users:
globalAdmin
globalOwner
globalMember
projectAdmin
2 Team projects:
ProjectA (admin == projectAdmin)
ProjectB
2 Workflows per Team and User
2 Credentials per Team
3 Tags
Mappings to all workflows
for each project 3 folders 2 top level, 1 child
1. Workflow moved in git to other project
*/
[globalAdmin, globalOwner, globalMember, projectAdmin] = await Promise.all([
await createUser({ role: 'global:admin' }),
await createUser({ role: 'global:owner' }),
await createUser({ role: 'global:member' }),
await createUser({ role: 'global:member' }),
]);
[projectA, projectB] = await Promise.all([
createTeamProject('ProjectA', projectAdmin),
createTeamProject('ProjectB'),
]);
let [
globalAdminWorkflows,
globalOwnerWorkflows,
globalMemberWorkflows,
projectAdminWorkflows,
projectAWorkflows,
projectBWorkflows,
] = await Promise.all(
[globalAdmin, globalOwner, globalMember, projectAdmin, projectA, projectB].map(
async (owner) => [
await createWorkflow(
{
name: `${owner.id}-WFA`,
},
owner,
),
await createWorkflow(
{
name: `${owner.id}-WFB`,
},
owner,
),
],
),
);
allWorkflows = [
...globalAdminWorkflows,
...globalOwnerWorkflows,
...globalMemberWorkflows,
...projectAdminWorkflows,
...projectAWorkflows,
...projectBWorkflows,
];
deletedOutOfScopeWorkflow = Object.assign(new WorkflowEntity(), {
id: 'deletedOutOfScope',
name: 'deletedOutOfScope',
});
deletedInScopeWorkflow = Object.assign(new WorkflowEntity(), {
id: 'deletedInScope',
name: 'deletedInScope',
});
deletedInScopeCredential = Object.assign(new CredentialsEntity(), {
id: 'deletedInScope',
name: 'deletedInScope',
data: '',
type: '',
});
deletedOutOfScopeCredential = Object.assign(new CredentialsEntity(), {
id: 'deletedOutOfScope',
name: 'deletedOutOfScope',
data: '',
type: '',
});
[
movedOutOfScopeCredential,
movedIntoScopeCredential,
movedOutOfScopeWorkflow,
movedIntoScopeWorkflow,
] = await Promise.all([
await createCredentials(
{
name: 'OutOfScope',
data: '',
type: '',
},
projectB,
),
await createCredentials(
{
name: 'IntoScope',
data: '',
type: '',
},
projectA,
),
await createWorkflow(
{
name: 'OutOfScope',
},
projectB,
),
await createWorkflow(
{
name: 'IntoScope',
},
projectA,
),
]);
let [projectACredentials, projectBCredentials] = await Promise.all(
[projectA, projectB].map(async (project) => [
await createCredentials(
{
name: `${project.name}-CredA`,
data: '',
type: '',
},
project,
),
await createCredentials(
{
name: `${project.name}-CredB`,
data: '',
type: '',
},
project,
),
]),
);
tags = await Promise.all([
createTag({
name: 'testTag1',
}),
createTag({
name: 'testTag2',
}),
createTag({
name: 'testTag3',
}),
]);
await Promise.all(
tags.map(async (tag) => {
await Promise.all(
allWorkflows.map(async (workflow) => {
await assignTagToWorkflow(tag, workflow);
}),
);
}),
);
let [projectAFolders, projectBFolders] = await Promise.all(
[projectA, projectB].map(async (project) => {
const parent = await createFolder(project, {
name: `${project.name}-FolderA`,
});
return [
parent,
await createFolder(project, {
name: `${project.name}-FolderB`,
}),
await createFolder(project, {
name: `${project.name}-FolderA.1`,
parentFolder: parent,
}),
];
}),
);
globalAdminScope = {
credentials: [],
workflows: globalAdminWorkflows,
folders: [],
};
globalOwnerScope = {
credentials: [],
workflows: globalOwnerWorkflows,
folders: [],
};
globalMemberScope = {
credentials: [],
workflows: globalMemberWorkflows,
folders: [],
};
projectAdminScope = {
credentials: [],
workflows: projectAdminWorkflows,
folders: [],
};
projectAScope = {
credentials: projectACredentials,
folders: projectAFolders,
workflows: projectAWorkflows,
};
projectBScope = {
credentials: projectBCredentials,
folders: projectBFolders,
workflows: projectBWorkflows,
};
service = Container.get(SourceControlService);
// Skip actual git operations
service.sanityCheck = async () => {};
service.resetWorkfolder = async () => undefined;
// Git mocking
gitFiles = {
'workflows/deletedOutOfScope.json': toExportableWorkflow(
deletedOutOfScopeWorkflow,
projectB,
),
'workflows/deletedInScope.json': toExportableWorkflow(deletedInScopeWorkflow, projectA),
'workflows/globalAdminWFA.json': toExportableWorkflow(globalAdminWorkflows[0], globalAdmin),
'workflows/globalOwnerWFA.json': toExportableWorkflow(globalOwnerWorkflows[0], globalOwner),
'workflows/globalMemberWFA.json': toExportableWorkflow(
globalMemberWorkflows[0],
globalMember,
),
'workflows/projectAdminWFA.json': toExportableWorkflow(
projectAdminWorkflows[0],
projectAdmin,
),
'workflows/projectAWFA.json': toExportableWorkflow(projectAWorkflows[0], projectA),
'workflows/projectBWFA.json': toExportableWorkflow(projectBWorkflows[0], projectB),
'workflows/outofscope.json': toExportableWorkflow(
movedOutOfScopeWorkflow,
projectA,
'otherID',
),
'workflows/intoscope.json': toExportableWorkflow(
movedIntoScopeWorkflow,
projectB,
'otherID',
),
'credential_stubs/AcredA.json': toExportableCredential(projectACredentials[0], projectA),
'credential_stubs/BcredA.json': toExportableCredential(projectBCredentials[0], projectB),
'credential_stubs/movedOutOfScopeCred.json': toExportableCredential(
movedOutOfScopeCredential,
projectB,
),
'credential_stubs/movedIntoScopeCred.json': toExportableCredential(
movedIntoScopeCredential,
projectA,
),
'credential_stubs/deletedOutOfScopeCred.json': toExportableCredential(
deletedOutOfScopeCredential,
projectB,
),
'credential_stubs/deletedIntoScopeCred.json': toExportableCredential(
deletedInScopeCredential,
projectA,
),
'folders.json': {
folders: [toExportableFolder(projectAFolders[0]), toExportableFolder(projectBFolders[0])],
},
'tags.json': {
tags: tags.map((t) => {
return {
id: t.id,
name: t.name,
};
}),
mappings: [
...globalAdminWorkflows.map((m) => {
return {
workflowId: m.id,
tagId: tags[0].id,
};
}),
],
},
};
globMock.mockImplementation(async (path, opts) => {
if (opts.cwd?.endsWith(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER)) {
// asking for workflows
return Object.keys(gitFiles).filter((file) =>
file.startsWith(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER),
);
} else if (opts.cwd?.endsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER)) {
// asking for credentials
return Object.keys(gitFiles).filter((file) =>
file.startsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER),
);
} else if (path === SOURCE_CONTROL_FOLDERS_EXPORT_FILE) {
// asking for folders
return ['folders.json'];
} else if (path === SOURCE_CONTROL_TAGS_EXPORT_FILE) {
// asking for folders
return ['tags.json'];
}
return [];
});
fsReadFile.mockImplementation(async (path: string) => {
return JSON.stringify(gitFiles[path]);
});
});
describe('direction: push', () => {
describe('global:admin user', () => {
it('should see all workflows', async () => {
let result = await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
// not existing in get status response
const notExisting = result.filter((wf) => {
return [
globalAdminScope.workflows[0],
globalOwnerScope.workflows[0],
globalMemberScope.workflows[0],
projectAdminScope.workflows[0],
projectAScope.workflows[0],
projectBScope.workflows[0],
]
.map((wf) => wf.id)
.some((id) => wf.id === id);
});
expect(notExisting).toBeEmptyArray();
const deletedWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'deleted',
);
// The created workflows
expect(new Set(deletedWorkflows.map((wf) => wf.id))).toEqual(
new Set([deletedOutOfScopeWorkflow.id, deletedInScopeWorkflow.id]),
);
const newWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'created',
);
// The created workflows
expect(new Set(newWorkflows.map((wf) => wf.id))).toEqual(
new Set([
globalAdminScope.workflows[1].id,
globalOwnerScope.workflows[1].id,
globalMemberScope.workflows[1].id,
projectAdminScope.workflows[1].id,
projectAScope.workflows[1].id,
projectBScope.workflows[1].id,
]),
);
const modifiedWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'modified',
);
// The modified workflows
expect(new Set(modifiedWorkflows.map((wf) => wf.id))).toEqual(
new Set([movedOutOfScopeWorkflow.id, movedIntoScopeWorkflow.id]),
);
});
it('should see all credentials', async () => {
let result = await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
const newCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'created',
);
const deletedCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'deleted',
);
const modifiedCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'modified',
);
expect(new Set(newCredentials.map((c) => c.id))).toEqual(
new Set([projectAScope.credentials[1].id, projectBScope.credentials[1].id]),
);
expect(new Set(deletedCredentials.map((c) => c.id))).toEqual(
new Set([deletedInScopeCredential.id, deletedOutOfScopeCredential.id]),
);
expect(modifiedCredentials).toBeEmptyArray();
// Make sure we checked all credential entries!
expect(result.filter((r) => r.type === 'credential')).toHaveLength(4);
});
it('should see all folder', async () => {
let result = await service.getStatus(globalAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
const folders = result.filter((r) => r.type === 'folders');
expect(new Set(folders.map((f) => f.id))).toEqual(
new Set([
projectAScope.folders[1].id,
projectAScope.folders[2].id,
projectBScope.folders[1].id,
projectBScope.folders[2].id,
]),
);
});
});
describe('global:member user', () => {
it('should see nothing', async () => {
let result = await service.getStatus(globalMember, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(result).toBeEmptyArray();
});
});
describe('project:Admin user', () => {
it('should see only workflows in correct scope', async () => {
let result = await service.getStatus(projectAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
// not existing in get status response
const notExisting = result.filter((wf) => {
return [
globalAdminScope.workflows[0],
globalOwnerScope.workflows[0],
globalMemberScope.workflows[0],
projectAdminScope.workflows[0],
globalAdminScope.workflows[1],
globalOwnerScope.workflows[1],
globalMemberScope.workflows[1],
projectAdminScope.workflows[1],
projectAScope.workflows[0],
projectBScope.workflows[0],
movedOutOfScopeWorkflow,
]
.map((wf) => wf.id)
.some((id) => wf.id === id);
});
expect(notExisting).toBeEmptyArray();
const deletedWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'deleted',
);
// The created workflows
expect(new Set(deletedWorkflows.map((wf) => wf.id))).toEqual(
new Set([deletedInScopeWorkflow.id]),
);
const newWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'created',
);
// The created workflows
expect(new Set(newWorkflows.map((wf) => wf.id))).toEqual(
new Set([projectAScope.workflows[1].id, movedIntoScopeWorkflow.id]),
);
const modifiedWorkflows = result.filter(
(r) => r.type === 'workflow' && r.status === 'modified',
);
// No modified workflows
expect(modifiedWorkflows).toBeEmptyArray();
});
it('should see only credentials in correct scope', async () => {
let result = await service.getStatus(projectAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
const newCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'created',
);
const deletedCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'deleted',
);
const modifiedCredentials = result.filter(
(r) => r.type === 'credential' && r.status === 'modified',
);
expect(new Set(newCredentials.map((c) => c.id))).toEqual(
new Set([projectAScope.credentials[1].id]),
);
expect(new Set(deletedCredentials.map((c) => c.id))).toEqual(
new Set([deletedInScopeCredential.id]),
);
expect(modifiedCredentials).toBeEmptyArray();
// Make sure we checked all credential entries!
expect(result.filter((r) => r.type === 'credential')).toHaveLength(2);
});
it('should see only folders in correct scope', async () => {
let result = await service.getStatus(projectAdmin, {
direction: 'push',
preferLocalVersion: true,
verbose: false,
});
expect(Array.isArray(result)).toBe(true);
if (!Array.isArray(result)) {
throw new Error('Cannot reach this, only needed as type guard');
}
const folders = result.filter((r) => r.type === 'folders');
expect(new Set(folders.map((f) => f.id))).toEqual(
new Set([projectAScope.folders[1].id, projectAScope.folders[2].id]),
);
});
});
});
});
});