diff --git a/packages/cli/src/PublicApi/types.ts b/packages/cli/src/PublicApi/types.ts index 4933edda55..b57080974b 100644 --- a/packages/cli/src/PublicApi/types.ts +++ b/packages/cli/src/PublicApi/types.ts @@ -5,6 +5,8 @@ import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import type { TagEntity } from '@db/entities/TagEntity'; + import type { UserManagementMailer } from '@/UserManagement/email'; import type { Risk } from '@/security-audit/types'; @@ -57,6 +59,24 @@ export declare namespace ExecutionRequest { type Delete = Get; } +export declare namespace TagRequest { + type GetAll = AuthenticatedRequest< + {}, + {}, + {}, + { + limit?: number; + cursor?: string; + offset?: number; + } + >; + + type Create = AuthenticatedRequest<{}, {}, TagEntity>; + type Get = AuthenticatedRequest<{ id: string }>; + type Delete = Get; + type Update = AuthenticatedRequest<{ id: string }, {}, TagEntity>; +} + export declare namespace CredentialTypeRequest { type Get = AuthenticatedRequest<{ credentialTypeName: string }, {}, {}, {}>; } @@ -74,6 +94,7 @@ export declare namespace WorkflowRequest { offset?: number; workflowId?: number; active: boolean; + name?: string; } >; @@ -82,6 +103,8 @@ export declare namespace WorkflowRequest { type Delete = Get; type Update = AuthenticatedRequest<{ id: string }, {}, WorkflowEntity, {}>; type Activate = Get; + type GetTags = Get; + type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>; } export declare namespace UserRequest { diff --git a/packages/cli/src/PublicApi/v1/handlers/tags/spec/paths/tags.id.yml b/packages/cli/src/PublicApi/v1/handlers/tags/spec/paths/tags.id.yml new file mode 100644 index 0000000000..8dfea3613e --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/tags/spec/paths/tags.id.yml @@ -0,0 +1,73 @@ +get: + x-eov-operation-id: getTag + x-eov-operation-handler: v1/handlers/tags/tags.handler + tags: + - Tags + summary: Retrieves a tag + description: Retrieves a tag. + parameters: + - $ref: '../schemas/parameters/tagId.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/tag.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' +delete: + x-eov-operation-id: deleteTag + x-eov-operation-handler: v1/handlers/tags/tags.handler + tags: + - Tags + summary: Delete a tag + description: Deletes a tag. + parameters: + - $ref: '../schemas/parameters/tagId.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/tag.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' +put: + x-eov-operation-id: updateTag + x-eov-operation-handler: v1/handlers/tags/tags.handler + tags: + - Tags + summary: Update a tag + description: Update a tag. + parameters: + - $ref: '../schemas/parameters/tagId.yml' + requestBody: + description: Updated tag object. + content: + application/json: + schema: + $ref: '../schemas/tag.yml' + required: true + responses: + '200': + description: Tag object + content: + application/json: + schema: + $ref: '../schemas/tag.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' + '409': + $ref: '../../../../shared/spec/responses/conflict.yml' \ No newline at end of file diff --git a/packages/cli/src/PublicApi/v1/handlers/tags/spec/paths/tags.yml b/packages/cli/src/PublicApi/v1/handlers/tags/spec/paths/tags.yml new file mode 100644 index 0000000000..dfef5e9722 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/tags/spec/paths/tags.yml @@ -0,0 +1,46 @@ +post: + x-eov-operation-id: createTag + x-eov-operation-handler: v1/handlers/tags/tags.handler + tags: + - Tags + summary: Create a tag + description: Create a tag in your instance. + requestBody: + description: Created tag object. + content: + application/json: + schema: + $ref: '../schemas/tag.yml' + required: true + responses: + '201': + description: A tag object + content: + application/json: + schema: + $ref: '../schemas/tag.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '409': + $ref: '../../../../shared/spec/responses/conflict.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' +get: + x-eov-operation-id: getTags + x-eov-operation-handler: v1/handlers/tags/tags.handler + tags: + - Tags + summary: Retrieve all tags + description: Retrieve all tags from your instance. + parameters: + - $ref: '../../../../shared/spec/parameters/limit.yml' + - $ref: '../../../../shared/spec/parameters/cursor.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/tagList.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/tags/spec/schemas/parameters/tagId.yml b/packages/cli/src/PublicApi/v1/handlers/tags/spec/schemas/parameters/tagId.yml new file mode 100644 index 0000000000..9a21818421 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/tags/spec/schemas/parameters/tagId.yml @@ -0,0 +1,6 @@ +name: id +in: path +description: The ID of the tag. +required: true +schema: + type: string diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tag.yml b/packages/cli/src/PublicApi/v1/handlers/tags/spec/schemas/tag.yml similarity index 66% rename from packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tag.yml rename to packages/cli/src/PublicApi/v1/handlers/tags/spec/schemas/tag.yml index fb9d024286..d0df086574 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tag.yml +++ b/packages/cli/src/PublicApi/v1/handlers/tags/spec/schemas/tag.yml @@ -1,8 +1,12 @@ type: object +additionalProperties: false +required: + - name properties: id: - type: number - example: 12 + type: string + readOnly: true + example: 2tUt1wbLX592XDdX name: type: string example: Production diff --git a/packages/cli/src/PublicApi/v1/handlers/tags/spec/schemas/tagList.yml b/packages/cli/src/PublicApi/v1/handlers/tags/spec/schemas/tagList.yml new file mode 100644 index 0000000000..37b63f9819 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/tags/spec/schemas/tagList.yml @@ -0,0 +1,11 @@ +type: object +properties: + data: + type: array + items: + $ref: './tag.yml' + nextCursor: + type: string + description: Paginate through tags by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. + nullable: true + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts b/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts new file mode 100644 index 0000000000..56e8f3b4b3 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts @@ -0,0 +1,103 @@ +import type express from 'express'; + +import type { TagEntity } from '@db/entities/TagEntity'; +import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; +import type { TagRequest } from '../../../types'; +import { encodeNextCursor } from '../../shared/services/pagination.service'; + +import { Container } from 'typedi'; +import type { FindManyOptions } from '@n8n/typeorm'; +import { TagRepository } from '@db/repositories/tag.repository'; +import { TagService } from '@/services/tag.service'; + +export = { + createTag: [ + authorize(['global:owner', 'global:admin', 'global:member']), + async (req: TagRequest.Create, res: express.Response): Promise => { + const { name } = req.body; + + const newTag = Container.get(TagService).toEntity({ name: name.trim() }); + + try { + const createdTag = await Container.get(TagService).save(newTag, 'create'); + return res.status(201).json(createdTag); + } catch (error) { + return res.status(409).json({ message: 'Tag already exists' }); + } + }, + ], + updateTag: [ + authorize(['global:owner', 'global:admin', 'global:member']), + async (req: TagRequest.Update, res: express.Response): Promise => { + const { id } = req.params; + const { name } = req.body; + + try { + await Container.get(TagService).getById(id); + } catch (error) { + return res.status(404).json({ message: 'Not Found' }); + } + + const updateTag = Container.get(TagService).toEntity({ id, name: name.trim() }); + + try { + const updatedTag = await Container.get(TagService).save(updateTag, 'update'); + return res.json(updatedTag); + } catch (error) { + return res.status(409).json({ message: 'Tag already exists' }); + } + }, + ], + deleteTag: [ + authorize(['global:owner', 'global:admin']), + async (req: TagRequest.Delete, res: express.Response): Promise => { + const { id } = req.params; + + let tag; + try { + tag = await Container.get(TagService).getById(id); + } catch (error) { + return res.status(404).json({ message: 'Not Found' }); + } + + await Container.get(TagService).delete(id); + return res.json(tag); + }, + ], + getTags: [ + authorize(['global:owner', 'global:admin', 'global:member']), + validCursor, + async (req: TagRequest.GetAll, res: express.Response): Promise => { + const { offset = 0, limit = 100 } = req.query; + + const query: FindManyOptions = { + skip: offset, + take: limit, + }; + + const [tags, count] = await Container.get(TagRepository).findAndCount(query); + + return res.json({ + data: tags, + nextCursor: encodeNextCursor({ + offset, + limit, + numberOfTotalRecords: count, + }), + }); + }, + ], + getTag: [ + authorize(['global:owner', 'global:admin', 'global:member']), + async (req: TagRequest.Get, res: express.Response): Promise => { + const { id } = req.params; + + try { + const tag = await Container.get(TagService).getById(id); + return res.json(tag); + } catch (error) { + return res.status(404).json({ message: 'Not Found' }); + } + }, + ], +}; diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.tags.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.tags.yml new file mode 100644 index 0000000000..1f5166d736 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.tags.yml @@ -0,0 +1,51 @@ +get: + x-eov-operation-id: getWorkflowTags + x-eov-operation-handler: v1/handlers/workflows/workflows.handler + tags: + - Workflow + summary: Get workflow tags + description: Get workflow tags. + parameters: + - $ref: '../schemas/parameters/workflowId.yml' + responses: + '200': + description: List of tags + content: + application/json: + schema: + $ref: '../schemas/workflowTags.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' +put: + x-eov-operation-id: updateWorkflowTags + x-eov-operation-handler: v1/handlers/workflows/workflows.handler + tags: + - Workflow + summary: Update tags of a workflow + description: Update tags of a workflow. + parameters: + - $ref: '../schemas/parameters/workflowId.yml' + requestBody: + description: List of tags + content: + application/json: + schema: + $ref: '../schemas/tagIds.yml' + required: true + responses: + '200': + description: List of tags after add the tag + content: + application/json: + schema: + $ref: '../schemas/workflowTags.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' \ No newline at end of file diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml index 1ff9f0a70d..6db149195d 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml @@ -44,6 +44,14 @@ get: schema: type: string example: test,production + - name: name + in: query + required: false + explode: false + allowReserved: true + schema: + type: string + example: My Workflow - $ref: '../../../../shared/spec/parameters/limit.yml' - $ref: '../../../../shared/spec/parameters/cursor.yml' responses: diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tagIds.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tagIds.yml new file mode 100644 index 0000000000..65ec23c7d6 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tagIds.yml @@ -0,0 +1,10 @@ +type: array +items: + type: object + additionalProperties: false + required: + - id + properties: + id: + type: string + example: 2tUt1wbLX592XDdX \ No newline at end of file diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflow.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflow.yml index 13000108d6..5d40d2d92c 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflow.yml +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflow.yml @@ -45,5 +45,5 @@ properties: tags: type: array items: - $ref: './tag.yml' + $ref: '../../../tags/spec/schemas/tag.yml' readOnly: true diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowTags.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowTags.yml new file mode 100644 index 0000000000..f5fbe154e5 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowTags.yml @@ -0,0 +1,3 @@ +type: array +items: + $ref: '../../../tags/spec/schemas/tag.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index e6435efd88..770ecac9ed 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -1,7 +1,8 @@ import type express from 'express'; + import { Container } from 'typedi'; import type { FindOptionsWhere } from '@n8n/typeorm'; -import { In } from '@n8n/typeorm'; +import { In, Like, QueryFailedError } from '@n8n/typeorm'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; @@ -20,6 +21,8 @@ import { updateWorkflow, createWorkflow, parseTagNames, + getWorkflowTags, + updateTags, } from './workflows.service'; import { WorkflowService } from '@/workflows/workflow.service'; import { InternalHooks } from '@/InternalHooks'; @@ -95,10 +98,11 @@ export = { authorize(['global:owner', 'global:admin', 'global:member']), validCursor, async (req: WorkflowRequest.GetAll, res: express.Response): Promise => { - const { offset = 0, limit = 100, active = undefined, tags = undefined } = req.query; + const { offset = 0, limit = 100, active, tags, name } = req.query; const where: FindOptionsWhere = { ...(active !== undefined && { active }), + ...(name !== undefined && { name: Like('%' + name.trim() + '%') }), }; if (['global:owner', 'global:admin'].includes(req.user.role)) { @@ -280,4 +284,59 @@ export = { return res.json(sharedWorkflow.workflow); }, ], + getWorkflowTags: [ + authorize(['global:owner', 'global:admin', 'global:member']), + async (req: WorkflowRequest.GetTags, res: express.Response): Promise => { + const { id } = req.params; + + if (config.getEnv('workflowTagsDisabled')) { + return res.status(400).json({ message: 'Workflow Tags Disabled' }); + } + + const sharedWorkflow = await getSharedWorkflow(req.user, id); + + if (!sharedWorkflow) { + // user trying to access a workflow he does not own + // or workflow does not exist + return res.status(404).json({ message: 'Not Found' }); + } + + const tags = await getWorkflowTags(id); + + return res.json(tags); + }, + ], + updateWorkflowTags: [ + authorize(['global:owner', 'global:admin', 'global:member']), + async (req: WorkflowRequest.UpdateTags, res: express.Response): Promise => { + const { id } = req.params; + const newTags = req.body.map((newTag) => newTag.id); + + if (config.getEnv('workflowTagsDisabled')) { + return res.status(400).json({ message: 'Workflow Tags Disabled' }); + } + + const sharedWorkflow = await getSharedWorkflow(req.user, id); + + if (!sharedWorkflow) { + // user trying to access a workflow he does not own + // or workflow does not exist + return res.status(404).json({ message: 'Not Found' }); + } + + let tags; + try { + await updateTags(id, newTags); + tags = await getWorkflowTags(id); + } catch (error) { + if (error instanceof QueryFailedError && error.message.includes('SQLITE_CONSTRAINT')) { + return res.status(404).json({ message: 'Some tags not found' }); + } else { + throw error; + } + } + + return res.json(tags); + }, + ], }; diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index 8d53a72ea1..39116eb7a1 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -2,10 +2,13 @@ import { Container } from 'typedi'; import * as Db from '@/Db'; import type { User } from '@db/entities/User'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; +import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping'; import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow'; import config from '@/config'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository'; +import { TagRepository } from '@db/repositories/tag.repository'; function insertIf(condition: boolean, elements: string[]): string[] { return condition ? elements : []; @@ -86,3 +89,29 @@ export async function updateWorkflow(workflowId: string, updateData: WorkflowEnt export function parseTagNames(tags: string): string[] { return tags.split(',').map((tag) => tag.trim()); } + +export async function getWorkflowTags(workflowId: string) { + return await Container.get(TagRepository).find({ + select: ['id', 'name', 'createdAt', 'updatedAt'], + where: { + workflowMappings: { + ...(workflowId && { workflowId }), + }, + }, + }); +} + +export async function updateTags(workflowId: string, newTags: string[]): Promise { + await Db.transaction(async (transactionManager) => { + const oldTags = await Container.get(WorkflowTagMappingRepository).findBy({ + workflowId, + }); + if (oldTags.length > 0) { + await transactionManager.delete(WorkflowTagMapping, oldTags); + } + await transactionManager.insert( + WorkflowTagMapping, + newTags.map((tagId) => ({ tagId, workflowId })), + ); + }); +} diff --git a/packages/cli/src/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml index 7dee98b6aa..9d82499835 100644 --- a/packages/cli/src/PublicApi/v1/openapi.yml +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -26,6 +26,8 @@ tags: description: Operations about workflows - name: Credential description: Operations about credentials + - name: Tags + description: Operations about tags - name: SourceControl description: Operations about source control @@ -42,6 +44,10 @@ paths: $ref: './handlers/executions/spec/paths/executions.yml' /executions/{id}: $ref: './handlers/executions/spec/paths/executions.id.yml' + /tags: + $ref: './handlers/tags/spec/paths/tags.yml' + /tags/{id}: + $ref: './handlers/tags/spec/paths/tags.id.yml' /workflows: $ref: './handlers/workflows/spec/paths/workflows.yml' /workflows/{id}: @@ -50,6 +56,8 @@ paths: $ref: './handlers/workflows/spec/paths/workflows.id.activate.yml' /workflows/{id}/deactivate: $ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml' + /workflows/{id}/tags: + $ref: './handlers/workflows/spec/paths/workflows.id.tags.yml' /users: $ref: './handlers/users/spec/paths/users.yml' /users/{id}: diff --git a/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml index b05b3cb5d5..c11178a7ae 100644 --- a/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml +++ b/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml @@ -6,6 +6,8 @@ ExecutionId: $ref: '../../../handlers/executions/spec/schemas/parameters/executionId.yml' WorkflowId: $ref: '../../../handlers/workflows/spec/schemas/parameters/workflowId.yml' +TagId: + $ref: '../../../handlers/tags/spec/schemas/parameters/tagId.yml' IncludeData: $ref: '../../../handlers/executions/spec/schemas/parameters/includeData.yml' UserIdentifier: diff --git a/packages/cli/src/PublicApi/v1/shared/spec/responses/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/responses/_index.yml index e5c738c345..f7894f665e 100644 --- a/packages/cli/src/PublicApi/v1/shared/spec/responses/_index.yml +++ b/packages/cli/src/PublicApi/v1/shared/spec/responses/_index.yml @@ -6,3 +6,5 @@ BadRequest: $ref: './badRequest.yml' Conflict: $ref: './conflict.yml' +Forbidden: + $ref: './forbidden.yml' diff --git a/packages/cli/src/PublicApi/v1/shared/spec/responses/forbidden.yml b/packages/cli/src/PublicApi/v1/shared/spec/responses/forbidden.yml new file mode 100644 index 0000000000..d977831a01 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/spec/responses/forbidden.yml @@ -0,0 +1 @@ +description: Forbidden diff --git a/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml index 9dd408f1d9..270bd06070 100644 --- a/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml +++ b/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml @@ -7,7 +7,7 @@ Execution: Node: $ref: './../../../handlers/workflows/spec/schemas/node.yml' Tag: - $ref: './../../../handlers/workflows/spec/schemas/tag.yml' + $ref: './../../../handlers/tags/spec/schemas/tag.yml' Workflow: $ref: './../../../handlers/workflows/spec/schemas/workflow.yml' WorkflowSettings: diff --git a/packages/cli/src/services/tag.service.ts b/packages/cli/src/services/tag.service.ts index f4f888efb4..c32ceef18c 100644 --- a/packages/cli/src/services/tag.service.ts +++ b/packages/cli/src/services/tag.service.ts @@ -64,6 +64,12 @@ export class TagService { }) as Promise>); } + async getById(id: string) { + return await this.tagRepository.findOneOrFail({ + where: { id }, + }); + } + /** * Sort tags based on the order of the tag IDs in the request. */ diff --git a/packages/cli/test/integration/publicApi/tags.test.ts b/packages/cli/test/integration/publicApi/tags.test.ts new file mode 100644 index 0000000000..7e8fcacdea --- /dev/null +++ b/packages/cli/test/integration/publicApi/tags.test.ts @@ -0,0 +1,337 @@ +import type { SuperAgentTest } from 'supertest'; +import Container from 'typedi'; +import type { User } from '@db/entities/User'; +import { TagRepository } from '@db/repositories/tag.repository'; + +import { randomApiKey } from '../shared/random'; +import * as utils from '../shared/utils/'; +import * as testDb from '../shared/testDb'; +import { createUser } from '../shared/db/users'; +import { createTag } from '../shared/db/tags'; + +let owner: User; +let member: User; +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; + +const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); + +beforeAll(async () => { + owner = await createUser({ + role: 'global:owner', + apiKey: randomApiKey(), + }); + + member = await createUser({ + role: 'global:member', + apiKey: randomApiKey(), + }); +}); + +beforeEach(async () => { + await testDb.truncate(['Tag']); + + authOwnerAgent = testServer.publicApiAgentFor(owner); + authMemberAgent = testServer.publicApiAgentFor(member); +}); + +const testWithAPIKey = + (method: 'get' | 'post' | 'put' | 'delete', url: string, apiKey: string | null) => async () => { + void authOwnerAgent.set({ 'X-N8N-API-KEY': apiKey }); + const response = await authOwnerAgent[method](url); + expect(response.statusCode).toBe(401); + }; + +describe('GET /tags', () => { + test('should fail due to missing API Key', testWithAPIKey('get', '/tags', null)); + + test('should fail due to invalid API Key', testWithAPIKey('get', '/tags', 'abcXYZ')); + + test('should return all tags', async () => { + await Promise.all([createTag({}), createTag({}), createTag({})]); + + const response = await authMemberAgent.get('/tags'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + expect(response.body.nextCursor).toBeNull(); + + for (const tag of response.body.data) { + const { id, name, createdAt, updatedAt } = tag; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + } + }); + + test('should return all tags with pagination', async () => { + await Promise.all([createTag({}), createTag({}), createTag({})]); + + const response = await authMemberAgent.get('/tags?limit=1'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.nextCursor).not.toBeNull(); + + const response2 = await authMemberAgent.get(`/tags?limit=1&cursor=${response.body.nextCursor}`); + + expect(response2.statusCode).toBe(200); + expect(response2.body.data.length).toBe(1); + expect(response2.body.nextCursor).not.toBeNull(); + expect(response2.body.nextCursor).not.toBe(response.body.nextCursor); + + const responses = [...response.body.data, ...response2.body.data]; + + for (const tag of responses) { + const { id, name, createdAt, updatedAt } = tag; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + } + + // check that we really received a different result + expect(response.body.data[0].id).not.toBe(response2.body.data[0].id); + }); +}); + +describe('GET /tags/:id', () => { + test('should fail due to missing API Key', testWithAPIKey('get', '/tags/gZqmqiGAuo1dHT7q', null)); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('get', '/tags/gZqmqiGAuo1dHT7q', 'abcXYZ'), + ); + + test('should fail due to non-existing tag', async () => { + const response = await authOwnerAgent.get('/tags/gZqmqiGAuo1dHT7q'); + + expect(response.statusCode).toBe(404); + }); + + test('should retrieve tag', async () => { + // create tag + const tag = await createTag({}); + + const response = await authMemberAgent.get(`/tags/${tag.id}`); + + expect(response.statusCode).toBe(200); + + const { id, name, createdAt, updatedAt } = response.body; + + expect(id).toEqual(tag.id); + expect(name).toEqual(tag.name); + expect(createdAt).toEqual(tag.createdAt.toISOString()); + expect(updatedAt).toEqual(tag.updatedAt.toISOString()); + }); +}); + +describe('DELETE /tags/:id', () => { + test( + 'should fail due to missing API Key', + testWithAPIKey('delete', '/tags/gZqmqiGAuo1dHT7q', null), + ); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('delete', '/tags/gZqmqiGAuo1dHT7q', 'abcXYZ'), + ); + + test('should fail due to non-existing tag', async () => { + const response = await authOwnerAgent.delete('/tags/gZqmqiGAuo1dHT7q'); + + expect(response.statusCode).toBe(404); + }); + + test('owner should delete the tag', async () => { + // create tag + const tag = await createTag({}); + + const response = await authOwnerAgent.delete(`/tags/${tag.id}`); + + expect(response.statusCode).toBe(200); + + const { id, name, createdAt, updatedAt } = response.body; + + expect(id).toEqual(tag.id); + expect(name).toEqual(tag.name); + expect(createdAt).toEqual(tag.createdAt.toISOString()); + expect(updatedAt).toEqual(tag.updatedAt.toISOString()); + + // make sure the tag actually deleted from the db + const deletedTag = await Container.get(TagRepository).findOneBy({ + id: tag.id, + }); + + expect(deletedTag).toBeNull(); + }); + + test('non-owner should not delete tag', async () => { + // create tag + const tag = await createTag({}); + + const response = await authMemberAgent.delete(`/tags/${tag.id}`); + + expect(response.statusCode).toBe(403); + + const { message } = response.body; + + expect(message).toEqual('Forbidden'); + + // make sure the tag was not deleted from the db + const notDeletedTag = await Container.get(TagRepository).findOneBy({ + id: tag.id, + }); + + expect(notDeletedTag).not.toBeNull(); + }); +}); + +describe('POST /tags', () => { + test('should fail due to missing API Key', testWithAPIKey('post', '/tags', null)); + + test('should fail due to invalid API Key', testWithAPIKey('post', '/tags', 'abcXYZ')); + + test('should fail due to invalid body', async () => { + const response = await authOwnerAgent.post('/tags').send({}); + + expect(response.statusCode).toBe(400); + }); + + test('should create tag', async () => { + const payload = { + name: 'Tag 1', + }; + + const response = await authMemberAgent.post('/tags').send(payload); + + expect(response.statusCode).toBe(201); + + const { id, name, createdAt, updatedAt } = response.body; + + expect(id).toBeDefined(); + expect(name).toBe(payload.name); + expect(createdAt).toBeDefined(); + expect(updatedAt).toEqual(createdAt); + + // check if created tag in DB + const tag = await Container.get(TagRepository).findOne({ + where: { + id, + }, + }); + + expect(tag?.name).toBe(name); + expect(tag?.createdAt.toISOString()).toEqual(createdAt); + expect(tag?.updatedAt.toISOString()).toEqual(updatedAt); + }); + + test('should not create tag if tag with same name exists', async () => { + const tag = { + name: 'Tag 1', + }; + + // create tag + await createTag(tag); + + const response = await authMemberAgent.post('/tags').send(tag); + + expect(response.statusCode).toBe(409); + + const { message } = response.body; + + expect(message).toBe('Tag already exists'); + }); +}); + +describe('PUT /tags/:id', () => { + test('should fail due to missing API Key', testWithAPIKey('put', '/tags/gZqmqiGAuo1dHT7q', null)); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('put', '/tags/gZqmqiGAuo1dHT7q', 'abcXYZ'), + ); + + test('should fail due to non-existing tag', async () => { + const response = await authOwnerAgent.put('/tags/gZqmqiGAuo1dHT7q').send({ + name: 'testing', + }); + + expect(response.statusCode).toBe(404); + }); + + test('should fail due to invalid body', async () => { + const response = await authOwnerAgent.put('/tags/gZqmqiGAuo1dHT7q').send({}); + + expect(response.statusCode).toBe(400); + }); + + test('should update tag', async () => { + const tag = await createTag({}); + + const payload = { + name: 'New name', + }; + + const response = await authOwnerAgent.put(`/tags/${tag.id}`).send(payload); + + const { id, name, updatedAt } = response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(tag.id); + expect(name).toBe(payload.name); + expect(updatedAt).not.toBe(tag.updatedAt.toISOString()); + + // check updated tag in DB + const dbTag = await Container.get(TagRepository).findOne({ + where: { + id, + }, + }); + + expect(dbTag?.name).toBe(payload.name); + expect(dbTag?.updatedAt.getTime()).toBeGreaterThan(tag.updatedAt.getTime()); + }); + + test('should fail if there is already a tag with a the new name', async () => { + const toUpdateTag = await createTag({}); + const otherTag = await createTag({ name: 'Some name' }); + + const payload = { + name: otherTag.name, + }; + + const response = await authOwnerAgent.put(`/tags/${toUpdateTag.id}`).send(payload); + + expect(response.statusCode).toBe(409); + + const { message } = response.body; + + expect(message).toBe('Tag already exists'); + + // check tags haven't be updated in DB + const toUpdateTagFromDb = await Container.get(TagRepository).findOne({ + where: { + id: toUpdateTag.id, + }, + }); + + expect(toUpdateTagFromDb?.name).toEqual(toUpdateTag.name); + expect(toUpdateTagFromDb?.createdAt.toISOString()).toEqual(toUpdateTag.createdAt.toISOString()); + expect(toUpdateTagFromDb?.updatedAt.toISOString()).toEqual(toUpdateTag.updatedAt.toISOString()); + + const otherTagFromDb = await Container.get(TagRepository).findOne({ + where: { + id: otherTag.id, + }, + }); + + expect(otherTagFromDb?.name).toEqual(otherTag.name); + expect(otherTagFromDb?.createdAt.toISOString()).toEqual(otherTag.createdAt.toISOString()); + expect(otherTagFromDb?.updatedAt.toISOString()).toEqual(otherTag.updatedAt.toISOString()); + }); +}); diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 305844453a..80a88f134c 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -1,4 +1,5 @@ import type { SuperAgentTest } from 'supertest'; +import config from '@/config'; import Container from 'typedi'; import type { INode } from 'n8n-workflow'; import { STARTING_NODES } from '@/constants'; @@ -250,6 +251,43 @@ describe('GET /workflows', () => { } }); + test('should return all owned workflows filtered by name', async () => { + const workflowName = 'Workflow 1'; + + const [workflow] = await Promise.all([ + createWorkflow({ name: workflowName }, member), + createWorkflow({}, member), + ]); + + const response = await authMemberAgent.get(`/workflows?name=${workflowName}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + + const { + id, + connections, + active, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + tags: wfTags, + } = response.body.data[0]; + + expect(id).toBeDefined(); + expect(name).toBe(workflowName); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + }); + test('should return all workflows for owner', async () => { await Promise.all([ createWorkflow({}, owner), @@ -1111,3 +1149,308 @@ describe('PUT /workflows/:id', () => { expect(sharedWorkflow?.role).toEqual('workflow:owner'); }); }); + +describe('GET /workflows/:id/tags', () => { + test('should fail due to missing API Key', testWithAPIKey('get', '/workflows/2/tags', null)); + + test('should fail due to invalid API Key', testWithAPIKey('get', '/workflows/2/tags', 'abcXYZ')); + + test('should fail if workflowTagsDisabled', async () => { + config.set('workflowTagsDisabled', true); + + const response = await authOwnerAgent.get('/workflows/2/tags'); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('Workflow Tags Disabled'); + }); + + test('should fail due to non-existing workflow', async () => { + config.set('workflowTagsDisabled', false); + + const response = await authOwnerAgent.get('/workflows/2/tags'); + + expect(response.statusCode).toBe(404); + }); + + test('should return all tags of owned workflow', async () => { + config.set('workflowTagsDisabled', false); + + const tags = await Promise.all([await createTag({}), await createTag({})]); + + const workflow = await createWorkflow({ tags }, member); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}/tags`); + + expect(response.statusCode).toBe(200); + expect(response.body.length).toBe(2); + + for (const tag of response.body) { + const { id, name, createdAt, updatedAt } = tag; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + tags.forEach((tag: TagEntity) => { + expect(tags.some((savedTag) => savedTag.id === tag.id)).toBe(true); + }); + } + }); + + test('should return empty array if workflow does not have tags', async () => { + config.set('workflowTagsDisabled', false); + + const workflow = await createWorkflow({}, member); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}/tags`); + + expect(response.statusCode).toBe(200); + expect(response.body.length).toBe(0); + }); +}); + +describe('PUT /workflows/:id/tags', () => { + test('should fail due to missing API Key', testWithAPIKey('put', '/workflows/2/tags', null)); + + test('should fail due to invalid API Key', testWithAPIKey('put', '/workflows/2/tags', 'abcXYZ')); + + test('should fail if workflowTagsDisabled', async () => { + config.set('workflowTagsDisabled', true); + + const response = await authOwnerAgent.put('/workflows/2/tags').send([]); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('Workflow Tags Disabled'); + }); + + test('should fail due to non-existing workflow', async () => { + config.set('workflowTagsDisabled', false); + + const response = await authOwnerAgent.put('/workflows/2/tags').send([]); + + expect(response.statusCode).toBe(404); + }); + + test('should add the tags, workflow have not got tags previously', async () => { + config.set('workflowTagsDisabled', false); + + const workflow = await createWorkflow({}, member); + const tags = await Promise.all([await createTag({}), await createTag({})]); + + const payload = [ + { + id: tags[0].id, + }, + { + id: tags[1].id, + }, + ]; + + const response = await authMemberAgent.put(`/workflows/${workflow.id}/tags`).send(payload); + + expect(response.statusCode).toBe(200); + expect(response.body.length).toBe(2); + + for (const tag of response.body) { + const { id, name, createdAt, updatedAt } = tag; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + tags.forEach((tag: TagEntity) => { + expect(tags.some((savedTag) => savedTag.id === tag.id)).toBe(true); + }); + } + + // Check the association in DB + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + userId: member.id, + workflowId: workflow.id, + }, + relations: ['workflow.tags'], + }); + + expect(sharedWorkflow?.workflow.tags).toBeDefined(); + expect(sharedWorkflow?.workflow.tags?.length).toBe(2); + if (sharedWorkflow?.workflow.tags !== undefined) { + for (const tag of sharedWorkflow?.workflow.tags) { + const { id, name, createdAt, updatedAt } = tag; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + tags.forEach((tag: TagEntity) => { + expect(tags.some((savedTag) => savedTag.id === tag.id)).toBe(true); + }); + } + } + }); + + 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 oldTags = [tags[0], tags[1]]; + const newTags = [tags[0], tags[2]]; + const workflow = await createWorkflow({ tags: oldTags }, member); + + // Check the association in DB + const oldSharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + userId: member.id, + workflowId: workflow.id, + }, + relations: ['workflow.tags'], + }); + + expect(oldSharedWorkflow?.workflow.tags).toBeDefined(); + expect(oldSharedWorkflow?.workflow.tags?.length).toBe(2); + if (oldSharedWorkflow?.workflow.tags !== undefined) { + for (const tag of oldSharedWorkflow?.workflow.tags) { + const { id, name, createdAt, updatedAt } = tag; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + oldTags.forEach((tag: TagEntity) => { + expect(oldTags.some((savedTag) => savedTag.id === tag.id)).toBe(true); + }); + } + } + + const payload = [ + { + id: newTags[0].id, + }, + { + id: newTags[1].id, + }, + ]; + + const response = await authMemberAgent.put(`/workflows/${workflow.id}/tags`).send(payload); + + expect(response.statusCode).toBe(200); + expect(response.body.length).toBe(2); + + for (const tag of response.body) { + const { id, name, createdAt, updatedAt } = tag; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + newTags.forEach((tag: TagEntity) => { + expect(newTags.some((savedTag) => savedTag.id === tag.id)).toBe(true); + }); + } + + // Check the association in DB + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + userId: member.id, + workflowId: workflow.id, + }, + relations: ['workflow.tags'], + }); + + expect(sharedWorkflow?.workflow.tags).toBeDefined(); + expect(sharedWorkflow?.workflow.tags?.length).toBe(2); + if (sharedWorkflow?.workflow.tags !== undefined) { + for (const tag of sharedWorkflow?.workflow.tags) { + const { id, name, createdAt, updatedAt } = tag; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + newTags.forEach((tag: TagEntity) => { + expect(newTags.some((savedTag) => savedTag.id === tag.id)).toBe(true); + }); + } + } + }); + + 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 oldTags = [tags[0], tags[1]]; + const workflow = await createWorkflow({ tags: oldTags }, member); + + // Check the association in DB + const oldSharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + userId: member.id, + workflowId: workflow.id, + }, + relations: ['workflow.tags'], + }); + + expect(oldSharedWorkflow?.workflow.tags).toBeDefined(); + expect(oldSharedWorkflow?.workflow.tags?.length).toBe(2); + if (oldSharedWorkflow?.workflow.tags !== undefined) { + for (const tag of oldSharedWorkflow?.workflow.tags) { + const { id, name, createdAt, updatedAt } = tag; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + oldTags.forEach((tag: TagEntity) => { + expect(oldTags.some((savedTag) => savedTag.id === tag.id)).toBe(true); + }); + } + } + + const payload = [ + { + id: oldTags[0].id, + }, + { + id: 'TagDoesNotExist', + }, + ]; + + const response = await authMemberAgent.put(`/workflows/${workflow.id}/tags`).send(payload); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Some tags not found'); + + // Check the association in DB + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + userId: member.id, + workflowId: workflow.id, + }, + relations: ['workflow.tags'], + }); + + expect(sharedWorkflow?.workflow.tags).toBeDefined(); + expect(sharedWorkflow?.workflow.tags?.length).toBe(2); + if (sharedWorkflow?.workflow.tags !== undefined) { + for (const tag of sharedWorkflow?.workflow.tags) { + const { id, name, createdAt, updatedAt } = tag; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + oldTags.forEach((tag: TagEntity) => { + expect(oldTags.some((savedTag) => savedTag.id === tag.id)).toBe(true); + }); + } + } + }); +});