refactor(core): Update tag endpoints to use DTOs and injectable config (#12380)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions

This commit is contained in:
Ricardo Espinoza 2025-01-09 14:17:11 -05:00 committed by GitHub
parent 95f055d23a
commit b1a40a231b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 282 additions and 103 deletions

View file

@ -44,3 +44,6 @@ export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto'; export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';
export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto'; export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto';
export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto';
export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto';

View file

@ -0,0 +1,37 @@
import { CreateOrUpdateTagRequestDto } from '../create-or-update-tag-request.dto';
describe('CreateOrUpdateTagRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid name',
request: {
name: 'tag-name',
},
},
])('should validate $name', ({ request }) => {
const result = CreateOrUpdateTagRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'empty tag name',
request: {
name: '',
},
expectedErrorPath: ['name'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = CreateOrUpdateTagRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,64 @@
import { RetrieveTagQueryDto } from '../retrieve-tag-query.dto';
describe('RetrieveTagQueryDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'with "true"',
request: {
withUsageCount: 'true',
},
},
{
name: 'with "false"',
request: {
withUsageCount: 'false',
},
},
])('should pass validation for withUsageCount $name', ({ request }) => {
const result = RetrieveTagQueryDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'with number',
request: {
withUsageCount: 1,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with boolean (true) ',
request: {
withUsageCount: true,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with boolean (false)',
request: {
withUsageCount: false,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with invalid string',
request: {
withUsageCount: 'invalid',
},
expectedErrorPath: ['withUsageCount'],
},
])('should fail validation for withUsageCount $name', ({ request, expectedErrorPath }) => {
const result = RetrieveTagQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class CreateOrUpdateTagRequestDto extends Z.class({
name: z.string().trim().min(1),
}) {}

View file

@ -0,0 +1,7 @@
import { Z } from 'zod-class';
import { booleanFromString } from '../../schemas/booleanFromString';
export class RetrieveTagQueryDto extends Z.class({
withUsageCount: booleanFromString.optional().default('false'),
}) {}

View file

@ -0,0 +1,10 @@
import { Config, Env } from '../decorators';
@Config
export class TagsConfig {
/*
Disable workflow tags
*/
@Env('N8N_WORKFLOW_TAGS_DISABLED')
disabled: boolean = false;
}

View file

@ -18,6 +18,7 @@ import { TaskRunnersConfig } from './configs/runners.config';
import { ScalingModeConfig } from './configs/scaling-mode.config'; import { ScalingModeConfig } from './configs/scaling-mode.config';
import { SecurityConfig } from './configs/security.config'; import { SecurityConfig } from './configs/security.config';
import { SentryConfig } from './configs/sentry.config'; import { SentryConfig } from './configs/sentry.config';
import { TagsConfig } from './configs/tags.config';
import { TemplatesConfig } from './configs/templates.config'; import { TemplatesConfig } from './configs/templates.config';
import { UserManagementConfig } from './configs/user-management.config'; import { UserManagementConfig } from './configs/user-management.config';
import { VersionNotificationsConfig } from './configs/version-notifications.config'; import { VersionNotificationsConfig } from './configs/version-notifications.config';
@ -125,4 +126,7 @@ export class GlobalConfig {
@Nested @Nested
aiAssistant: AiAssistantConfig; aiAssistant: AiAssistantConfig;
@Nested
tags: TagsConfig;
} }

View file

@ -295,6 +295,9 @@ describe('GlobalConfig', () => {
aiAssistant: { aiAssistant: {
baseUrl: '', baseUrl: '',
}, },
tags: {
disabled: false,
},
}; };
it('should use all default values when no env variables are defined', () => { it('should use all default values when no env variables are defined', () => {

View file

@ -139,13 +139,6 @@ export const schema = {
doc: 'Public URL where the editor is accessible. Also used for emails sent from n8n.', doc: 'Public URL where the editor is accessible. Also used for emails sent from n8n.',
}, },
workflowTagsDisabled: {
format: Boolean,
default: false,
env: 'N8N_WORKFLOW_TAGS_DISABLED',
doc: 'Disable workflow tags.',
},
userManagement: { userManagement: {
jwtSecret: { jwtSecret: {
doc: 'Set a specific JWT secret (optional - n8n can generate one)', // Generated @ start.ts doc: 'Set a specific JWT secret (optional - n8n can generate one)', // Generated @ start.ts

View file

@ -1,54 +1,60 @@
import { Request, Response, NextFunction } from 'express'; import { CreateOrUpdateTagRequestDto, RetrieveTagQueryDto } from '@n8n/api-types';
import { Response } from 'express';
import config from '@/config'; import {
import { Delete, Get, Middleware, Patch, Post, RestController, GlobalScope } from '@/decorators'; Delete,
import { BadRequestError } from '@/errors/response-errors/bad-request.error'; Get,
import { TagsRequest } from '@/requests'; Patch,
Post,
RestController,
GlobalScope,
Body,
Param,
Query,
} from '@/decorators';
import { AuthenticatedRequest } from '@/requests';
import { TagService } from '@/services/tag.service'; import { TagService } from '@/services/tag.service';
@RestController('/tags') @RestController('/tags')
export class TagsController { export class TagsController {
private config = config;
constructor(private readonly tagService: TagService) {} constructor(private readonly tagService: TagService) {}
// TODO: move this into a new decorator `@IfEnabled('workflowTagsDisabled')`
@Middleware()
workflowsEnabledMiddleware(_req: Request, _res: Response, next: NextFunction) {
if (this.config.getEnv('workflowTagsDisabled'))
throw new BadRequestError('Workflow tags are disabled');
next();
}
@Get('/') @Get('/')
@GlobalScope('tag:list') @GlobalScope('tag:list')
async getAll(req: TagsRequest.GetAll) { async getAll(_req: AuthenticatedRequest, _res: Response, @Query query: RetrieveTagQueryDto) {
return await this.tagService.getAll({ withUsageCount: req.query.withUsageCount === 'true' }); return await this.tagService.getAll({ withUsageCount: query.withUsageCount });
} }
@Post('/') @Post('/')
@GlobalScope('tag:create') @GlobalScope('tag:create')
async createTag(req: TagsRequest.Create) { async createTag(
const tag = this.tagService.toEntity({ name: req.body.name }); _req: AuthenticatedRequest,
_res: Response,
@Body payload: CreateOrUpdateTagRequestDto,
) {
const { name } = payload;
const tag = this.tagService.toEntity({ name });
return await this.tagService.save(tag, 'create'); return await this.tagService.save(tag, 'create');
} }
@Patch('/:id(\\w+)') @Patch('/:id(\\w+)')
@GlobalScope('tag:update') @GlobalScope('tag:update')
async updateTag(req: TagsRequest.Update) { async updateTag(
const newTag = this.tagService.toEntity({ id: req.params.id, name: req.body.name.trim() }); _req: AuthenticatedRequest,
_res: Response,
@Param('id') tagId: string,
@Body payload: CreateOrUpdateTagRequestDto,
) {
const newTag = this.tagService.toEntity({ id: tagId, name: payload.name });
return await this.tagService.save(newTag, 'update'); return await this.tagService.save(newTag, 'update');
} }
@Delete('/:id(\\w+)') @Delete('/:id(\\w+)')
@GlobalScope('tag:delete') @GlobalScope('tag:delete')
async deleteTag(req: TagsRequest.Delete) { async deleteTag(_req: AuthenticatedRequest, _res: Response, @Param('id') tagId: string) {
const { id } = req.params; await this.tagService.delete(tagId);
await this.tagService.delete(id);
return true; return true;
} }
} }

View file

@ -12,7 +12,6 @@ import {
type FindOptionsRelations, type FindOptionsRelations,
} from '@n8n/typeorm'; } from '@n8n/typeorm';
import config from '@/config';
import type { ListQuery } from '@/requests'; import type { ListQuery } from '@/requests';
import { isStringArray } from '@/utils'; import { isStringArray } from '@/utils';
@ -132,7 +131,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
const relations: string[] = []; const relations: string[] = [];
const areTagsEnabled = !config.getEnv('workflowTagsDisabled'); const areTagsEnabled = !this.globalConfig.tags.disabled;
const isDefaultSelect = options?.select === undefined; const isDefaultSelect = options?.select === undefined;
const areTagsRequested = isDefaultSelect || options?.select?.tags === true; const areTagsRequested = isDefaultSelect || options?.select?.tags === true;
const isOwnedByIncluded = isDefaultSelect || options?.select?.ownedBy === true; const isOwnedByIncluded = isDefaultSelect || options?.select?.ownedBy === true;

View file

@ -1,14 +1,14 @@
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, Like, QueryFailedError } from '@n8n/typeorm'; import { In, Like, QueryFailedError } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { FindOptionsWhere } from '@n8n/typeorm';
import type express from 'express'; import type express from 'express';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { z } from 'zod'; import { z } from 'zod';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { ActiveWorkflowManager } from '@/active-workflow-manager';
import config from '@/config';
import { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
@ -111,7 +111,7 @@ export = {
id, id,
req.user, req.user,
['workflow:read'], ['workflow:read'],
{ includeTags: !config.getEnv('workflowTagsDisabled') }, { includeTags: !Container.get(GlobalConfig).tags.disabled },
); );
if (!workflow) { if (!workflow) {
@ -209,7 +209,7 @@ export = {
skip: offset, skip: offset,
take: limit, take: limit,
where, where,
...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }), ...(!Container.get(GlobalConfig).tags.disabled && { relations: ['tags'] }),
}); });
if (excludePinnedData) { if (excludePinnedData) {
@ -379,7 +379,7 @@ export = {
async (req: WorkflowRequest.GetTags, res: express.Response): Promise<express.Response> => { async (req: WorkflowRequest.GetTags, res: express.Response): Promise<express.Response> => {
const { id } = req.params; const { id } = req.params;
if (config.getEnv('workflowTagsDisabled')) { if (Container.get(GlobalConfig).tags.disabled) {
return res.status(400).json({ message: 'Workflow Tags Disabled' }); return res.status(400).json({ message: 'Workflow Tags Disabled' });
} }
@ -406,7 +406,7 @@ export = {
const { id } = req.params; const { id } = req.params;
const newTags = req.body.map((newTag) => newTag.id); const newTags = req.body.map((newTag) => newTag.id);
if (config.getEnv('workflowTagsDisabled')) { if (Container.get(GlobalConfig).tags.disabled) {
return res.status(400).json({ message: 'Workflow Tags Disabled' }); return res.status(400).json({ message: 'Workflow Tags Disabled' });
} }

View file

@ -1,7 +1,7 @@
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
import config from '@/config';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import { SharedWorkflow, type WorkflowSharingRole } from '@/databases/entities/shared-workflow'; import { SharedWorkflow, type WorkflowSharingRole } from '@/databases/entities/shared-workflow';
import type { User } from '@/databases/entities/user'; import type { User } from '@/databases/entities/user';
@ -46,7 +46,10 @@ export async function getSharedWorkflow(
...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }), ...(!['global:owner', 'global:admin'].includes(user.role) && { userId: user.id }),
...(workflowId && { workflowId }), ...(workflowId && { workflowId }),
}, },
relations: [...insertIf(!config.getEnv('workflowTagsDisabled'), ['workflow.tags']), 'workflow'], relations: [
...insertIf(!Container.get(GlobalConfig).tags.disabled, ['workflow.tags']),
'workflow',
],
}); });
} }

View file

@ -264,17 +264,6 @@ export declare namespace OAuthRequest {
} }
} }
// ----------------------------------
// /tags
// ----------------------------------
export declare namespace TagsRequest {
type GetAll = AuthenticatedRequest<{}, {}, {}, { withUsageCount: string }>;
type Create = AuthenticatedRequest<{}, {}, { name: string }>;
type Update = AuthenticatedRequest<{ id: string }, {}, { name: string }>;
type Delete = AuthenticatedRequest<{ id: string }>;
}
// ---------------------------------- // ----------------------------------
// /annotation-tags // /annotation-tags
// ---------------------------------- // ----------------------------------

View file

@ -135,6 +135,9 @@ export class Server extends AbstractServer {
await import('@/controllers/cta.controller'); await import('@/controllers/cta.controller');
} }
if (!this.globalConfig.tags.disabled) {
await import('@/controllers/tags.controller');
}
// ---------------------------------------- // ----------------------------------------
// SAML // SAML
// ---------------------------------------- // ----------------------------------------

View file

@ -154,7 +154,7 @@ export class FrontendService {
enabled: !this.globalConfig.publicApi.swaggerUiDisabled, enabled: !this.globalConfig.publicApi.swaggerUiDisabled,
}, },
}, },
workflowTagsDisabled: config.getEnv('workflowTagsDisabled'), workflowTagsDisabled: this.globalConfig.tags.disabled,
logLevel: this.globalConfig.logging.level, logLevel: this.globalConfig.logging.level,
hiringBannerEnabled: config.getEnv('hiringBanner.enabled'), hiringBannerEnabled: config.getEnv('hiringBanner.enabled'),
aiAssistant: { aiAssistant: {

View file

@ -42,7 +42,6 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import config from '@/config';
import { CredentialsHelper } from '@/credentials-helper'; import { CredentialsHelper } from '@/credentials-helper';
import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { AiEventMap, AiEventPayload } from '@/events/maps/ai.event-map'; import type { AiEventMap, AiEventPayload } from '@/events/maps/ai.event-map';
@ -734,7 +733,7 @@ export async function getWorkflowData(
let workflowData: IWorkflowBase | null; let workflowData: IWorkflowBase | null;
if (workflowInfo.id !== undefined) { if (workflowInfo.id !== undefined) {
const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags']; const relations = Container.get(GlobalConfig).tags.disabled ? [] : ['tags'];
workflowData = await Container.get(WorkflowRepository).get( workflowData = await Container.get(WorkflowRepository).get(
{ id: workflowInfo.id }, { id: workflowInfo.id },

View file

@ -1,3 +1,4 @@
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
@ -54,6 +55,7 @@ export class WorkflowService {
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly executionRepository: ExecutionRepository, private readonly executionRepository: ExecutionRepository,
private readonly eventService: EventService, private readonly eventService: EventService,
private readonly globalConfig: GlobalConfig,
) {} ) {}
async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) { async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) {
@ -202,7 +204,9 @@ export class WorkflowService {
]), ]),
); );
if (tagIds && !config.getEnv('workflowTagsDisabled')) { const tagsDisabled = this.globalConfig.tags.disabled;
if (tagIds && !tagsDisabled) {
await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds); await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds);
} }
@ -210,7 +214,7 @@ export class WorkflowService {
await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId); await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId);
} }
const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags']; const relations = tagsDisabled ? [] : ['tags'];
// We sadly get nothing back from "update". Neither if it updated a record // We sadly get nothing back from "update". Neither if it updated a record
// nor the new value. So query now the hopefully updated entry. // nor the new value. So query now the hopefully updated entry.

View file

@ -89,7 +89,7 @@ export class WorkflowsController {
const { tags: tagIds } = req.body; const { tags: tagIds } = req.body;
if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) { if (tagIds?.length && !this.globalConfig.tags.disabled) {
newWorkflow.tags = await this.tagRepository.findMany(tagIds); newWorkflow.tags = await this.tagRepository.findMany(tagIds);
} }
@ -164,7 +164,7 @@ export class WorkflowsController {
await this.workflowHistoryService.saveVersion(req.user, savedWorkflow, savedWorkflow.id); await this.workflowHistoryService.saveVersion(req.user, savedWorkflow, savedWorkflow.id);
if (tagIds && !config.getEnv('workflowTagsDisabled') && savedWorkflow.tags) { if (tagIds && !this.globalConfig.tags.disabled && savedWorkflow.tags) {
savedWorkflow.tags = this.tagService.sortByRequestOrder(savedWorkflow.tags, { savedWorkflow.tags = this.tagService.sortByRequestOrder(savedWorkflow.tags, {
requestOrder: tagIds, requestOrder: tagIds,
}); });
@ -260,7 +260,7 @@ export class WorkflowsController {
}, },
}; };
if (!config.getEnv('workflowTagsDisabled')) { if (!this.globalConfig.tags.disabled) {
relations.tags = true; relations.tags = true;
} }
@ -268,7 +268,7 @@ export class WorkflowsController {
workflowId, workflowId,
req.user, req.user,
['workflow:read'], ['workflow:read'],
{ includeTags: !config.getEnv('workflowTagsDisabled') }, { includeTags: !this.globalConfig.tags.disabled },
); );
if (!workflow) { if (!workflow) {
@ -296,7 +296,7 @@ export class WorkflowsController {
workflowId, workflowId,
req.user, req.user,
['workflow:read'], ['workflow:read'],
{ includeTags: !config.getEnv('workflowTagsDisabled') }, { includeTags: !this.globalConfig.tags.disabled },
); );
if (!workflow) { if (!workflow) {

View file

@ -1,8 +1,8 @@
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { INode } from 'n8n-workflow'; import type { INode } from 'n8n-workflow';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { ActiveWorkflowManager } from '@/active-workflow-manager';
import config from '@/config';
import { STARTING_NODES } from '@/constants'; import { STARTING_NODES } from '@/constants';
import type { Project } from '@/databases/entities/project'; import type { Project } from '@/databases/entities/project';
import type { TagEntity } from '@/databases/entities/tag-entity'; import type { TagEntity } from '@/databases/entities/tag-entity';
@ -36,6 +36,8 @@ let activeWorkflowManager: ActiveWorkflowManager;
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
const license = testServer.license; const license = testServer.license;
const globalConfig = Container.get(GlobalConfig);
mockInstance(ExecutionService); mockInstance(ExecutionService);
beforeAll(async () => { beforeAll(async () => {
@ -69,6 +71,8 @@ beforeEach(async () => {
authOwnerAgent = testServer.publicApiAgentFor(owner); authOwnerAgent = testServer.publicApiAgentFor(owner);
authMemberAgent = testServer.publicApiAgentFor(member); authMemberAgent = testServer.publicApiAgentFor(member);
globalConfig.tags.disabled = false;
}); });
afterEach(async () => { afterEach(async () => {
@ -1287,8 +1291,8 @@ describe('GET /workflows/:id/tags', () => {
test('should fail due to invalid API Key', testWithAPIKey('get', '/workflows/2/tags', 'abcXYZ')); test('should fail due to invalid API Key', testWithAPIKey('get', '/workflows/2/tags', 'abcXYZ'));
test('should fail if workflowTagsDisabled', async () => { test('should fail if N8N_WORKFLOW_TAGS_DISABLED', async () => {
config.set('workflowTagsDisabled', true); globalConfig.tags.disabled = true;
const response = await authOwnerAgent.get('/workflows/2/tags'); const response = await authOwnerAgent.get('/workflows/2/tags');
@ -1297,16 +1301,12 @@ describe('GET /workflows/:id/tags', () => {
}); });
test('should fail due to non-existing workflow', async () => { test('should fail due to non-existing workflow', async () => {
config.set('workflowTagsDisabled', false);
const response = await authOwnerAgent.get('/workflows/2/tags'); const response = await authOwnerAgent.get('/workflows/2/tags');
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
}); });
test('should return all tags of owned workflow', async () => { test('should return all tags of owned workflow', async () => {
config.set('workflowTagsDisabled', false);
const tags = await Promise.all([await createTag({}), await createTag({})]); const tags = await Promise.all([await createTag({}), await createTag({})]);
const workflow = await createWorkflow({ tags }, member); const workflow = await createWorkflow({ tags }, member);
@ -1331,8 +1331,6 @@ describe('GET /workflows/:id/tags', () => {
}); });
test('should return empty array if workflow does not have tags', async () => { test('should return empty array if workflow does not have tags', async () => {
config.set('workflowTagsDisabled', false);
const workflow = await createWorkflow({}, member); const workflow = await createWorkflow({}, member);
const response = await authMemberAgent.get(`/workflows/${workflow.id}/tags`); const response = await authMemberAgent.get(`/workflows/${workflow.id}/tags`);
@ -1347,8 +1345,8 @@ describe('PUT /workflows/:id/tags', () => {
test('should fail due to invalid API Key', testWithAPIKey('put', '/workflows/2/tags', 'abcXYZ')); test('should fail due to invalid API Key', testWithAPIKey('put', '/workflows/2/tags', 'abcXYZ'));
test('should fail if workflowTagsDisabled', async () => { test('should fail if N8N_WORKFLOW_TAGS_DISABLED', async () => {
config.set('workflowTagsDisabled', true); globalConfig.tags.disabled = true;
const response = await authOwnerAgent.put('/workflows/2/tags').send([]); const response = await authOwnerAgent.put('/workflows/2/tags').send([]);
@ -1357,16 +1355,12 @@ describe('PUT /workflows/:id/tags', () => {
}); });
test('should fail due to non-existing workflow', async () => { test('should fail due to non-existing workflow', async () => {
config.set('workflowTagsDisabled', false);
const response = await authOwnerAgent.put('/workflows/2/tags').send([]); const response = await authOwnerAgent.put('/workflows/2/tags').send([]);
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
}); });
test('should add the tags, workflow have not got tags previously', async () => { test('should add the tags, workflow have not got tags previously', async () => {
config.set('workflowTagsDisabled', false);
const workflow = await createWorkflow({}, member); const workflow = await createWorkflow({}, member);
const tags = await Promise.all([await createTag({}), await createTag({})]); const tags = await Promise.all([await createTag({}), await createTag({})]);
@ -1425,8 +1419,6 @@ describe('PUT /workflows/:id/tags', () => {
}); });
test('should add the tags, workflow have some tags previously', async () => { test('should add the tags, workflow have some tags previously', async () => {
config.set('workflowTagsDisabled', false);
const tags = await Promise.all([await createTag({}), await createTag({}), await createTag({})]); const tags = await Promise.all([await createTag({}), await createTag({}), await createTag({})]);
const oldTags = [tags[0], tags[1]]; const oldTags = [tags[0], tags[1]];
const newTags = [tags[0], tags[2]]; const newTags = [tags[0], tags[2]];
@ -1513,8 +1505,6 @@ describe('PUT /workflows/:id/tags', () => {
}); });
test('should fail to add the tags as one does not exist, workflow should maintain previous tags', async () => { test('should fail to add the tags as one does not exist, workflow should maintain previous tags', async () => {
config.set('workflowTagsDisabled', false);
const tags = await Promise.all([await createTag({}), await createTag({})]); const tags = await Promise.all([await createTag({}), await createTag({})]);
const oldTags = [tags[0], tags[1]]; const oldTags = [tags[0], tags[1]];
const workflow = await createWorkflow({ tags: oldTags }, member); const workflow = await createWorkflow({ tags: oldTags }, member);

View file

@ -240,6 +240,7 @@ test('should not report outdated instance when up to date', async () => {
test('should report security settings', async () => { test('should report security settings', async () => {
Container.get(GlobalConfig).diagnostics.enabled = true; Container.get(GlobalConfig).diagnostics.enabled = true;
const testAudit = await securityAuditService.run(['instance']); const testAudit = await securityAuditService.run(['instance']);
const section = getRiskSection( const section = getRiskSection(

View file

@ -8,6 +8,7 @@ import type { SuperAgentTest } from './shared/types';
import * as utils from './shared/utils/'; import * as utils from './shared/utils/';
let authOwnerAgent: SuperAgentTest; let authOwnerAgent: SuperAgentTest;
const testServer = utils.setupTestServer({ endpointGroups: ['tags'] }); const testServer = utils.setupTestServer({ endpointGroups: ['tags'] });
beforeAll(async () => { beforeAll(async () => {
@ -22,8 +23,8 @@ beforeEach(async () => {
describe('POST /tags', () => { describe('POST /tags', () => {
test('should create tag', async () => { test('should create tag', async () => {
const resp = await authOwnerAgent.post('/tags').send({ name: 'test' }); const resp = await authOwnerAgent.post('/tags').send({ name: 'test' });
expect(resp.statusCode).toBe(200);
expect(resp.statusCode).toBe(200);
const dbTag = await Container.get(TagRepository).findBy({ name: 'test' }); const dbTag = await Container.get(TagRepository).findBy({ name: 'test' });
expect(dbTag.length === 1); expect(dbTag.length === 1);
}); });
@ -38,4 +39,59 @@ describe('POST /tags', () => {
const dbTag = await Container.get(TagRepository).findBy({ name: 'test' }); const dbTag = await Container.get(TagRepository).findBy({ name: 'test' });
expect(dbTag.length).toBe(1); expect(dbTag.length).toBe(1);
}); });
test('should delete tag', async () => {
const newTag = Container.get(TagRepository).create({ name: 'test' });
await Container.get(TagRepository).save(newTag);
const resp = await authOwnerAgent.delete(`/tags/${newTag.id}`);
expect(resp.status).toBe(200);
const dbTag = await Container.get(TagRepository).findBy({ name: 'test' });
expect(dbTag.length).toBe(0);
});
test('should update tag name', async () => {
const newTag = Container.get(TagRepository).create({ name: 'test' });
await Container.get(TagRepository).save(newTag);
const resp = await authOwnerAgent.patch(`/tags/${newTag.id}`).send({ name: 'updated' });
expect(resp.status).toBe(200);
const dbTag = await Container.get(TagRepository).findBy({ name: 'updated' });
expect(dbTag.length).toBe(1);
});
test('should retrieve all tags', async () => {
const newTag = Container.get(TagRepository).create({ name: 'test' });
const savedTag = await Container.get(TagRepository).save(newTag);
const resp = await authOwnerAgent.get('/tags');
expect(resp.status).toBe(200);
expect(resp.body.data.length).toBe(1);
expect(resp.body.data[0]).toMatchObject({
id: savedTag.id,
name: savedTag.name,
createdAt: savedTag.createdAt.toISOString(),
updatedAt: savedTag.updatedAt.toISOString(),
});
});
test('should retrieve all tags with with usage count', async () => {
const newTag = Container.get(TagRepository).create({ name: 'test' });
const savedTag = await Container.get(TagRepository).save(newTag);
const resp = await authOwnerAgent.get('/tags').query({ withUsageCount: 'true' });
expect(resp.status).toBe(200);
expect(resp.body.data.length).toBe(1);
expect(resp.body.data[0]).toMatchObject({
id: savedTag.id,
name: savedTag.name,
createdAt: savedTag.createdAt.toISOString(),
updatedAt: savedTag.updatedAt.toISOString(),
usageCount: 0,
});
});
}); });

View file

@ -40,6 +40,7 @@ beforeAll(async () => {
mock(), mock(),
mock(), mock(),
mock(), mock(),
mock(),
); );
}); });

View file

@ -20,3 +20,8 @@ writeFileSync(
mode: 0o600, mode: 0o600,
}, },
); );
// This is needed to ensure that `process.env` overrides in tests
// are set before any of the config classes are instantiated.
// TODO: delete this after we are done migrating everything to config classes
import '@/config';

View file

@ -1,29 +1,26 @@
import type { IRestApiContext, ITag } from '@/Interface'; import type { IRestApiContext, ITag } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
import type { CreateOrUpdateTagRequestDto, RetrieveTagQueryDto } from '@n8n/api-types';
type TagsApiEndpoint = '/tags' | '/annotation-tags'; type TagsApiEndpoint = '/tags' | '/annotation-tags';
export interface ITagsApi { export function createTagsApi(endpoint: TagsApiEndpoint) {
getTags: (context: IRestApiContext, withUsageCount?: boolean) => Promise<ITag[]>;
createTag: (context: IRestApiContext, params: { name: string }) => Promise<ITag>;
updateTag: (context: IRestApiContext, id: string, params: { name: string }) => Promise<ITag>;
deleteTag: (context: IRestApiContext, id: string) => Promise<boolean>;
}
export function createTagsApi(endpoint: TagsApiEndpoint): ITagsApi {
return { return {
getTags: async (context: IRestApiContext, withUsageCount = false): Promise<ITag[]> => { getTags: async (context: IRestApiContext, data: RetrieveTagQueryDto): Promise<ITag[]> => {
return await makeRestApiRequest(context, 'GET', endpoint, { withUsageCount }); return await makeRestApiRequest(context, 'GET', endpoint, data);
}, },
createTag: async (context: IRestApiContext, params: { name: string }): Promise<ITag> => { createTag: async (
return await makeRestApiRequest(context, 'POST', endpoint, params); context: IRestApiContext,
data: CreateOrUpdateTagRequestDto,
): Promise<ITag> => {
return await makeRestApiRequest(context, 'POST', endpoint, data);
}, },
updateTag: async ( updateTag: async (
context: IRestApiContext, context: IRestApiContext,
id: string, id: string,
params: { name: string }, data: CreateOrUpdateTagRequestDto,
): Promise<ITag> => { ): Promise<ITag> => {
return await makeRestApiRequest(context, 'PATCH', `${endpoint}/${id}`, params); return await makeRestApiRequest(context, 'PATCH', `${endpoint}/${id}`, data);
}, },
deleteTag: async (context: IRestApiContext, id: string): Promise<boolean> => { deleteTag: async (context: IRestApiContext, id: string): Promise<boolean> => {
return await makeRestApiRequest(context, 'DELETE', `${endpoint}/${id}`); return await makeRestApiRequest(context, 'DELETE', `${endpoint}/${id}`);

View file

@ -80,10 +80,9 @@ const createTagsStore = (id: STORES.TAGS | STORES.ANNOTATION_TAGS) => {
} }
loading.value = true; loading.value = true;
const retrievedTags = await tagsApi.getTags( const retrievedTags = await tagsApi.getTags(rootStore.restApiContext, {
rootStore.restApiContext, withUsageCount,
Boolean(withUsageCount), });
);
setAllTags(retrievedTags); setAllTags(retrievedTags);
loading.value = false; loading.value = false;
return retrievedTags; return retrievedTags;