mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
feat(API): Add tag support to public API (#8588)
Co-authored-by: Jesús Burgers <jesus.burgers@chakray.co.uk> Co-authored-by: Jesús Burgers <43568066+jburgers-chakray@users.noreply.github.com>
This commit is contained in:
parent
64b10d7f5c
commit
a743a40376
|
@ -5,6 +5,8 @@ import type { User } from '@db/entities/User';
|
||||||
|
|
||||||
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||||
|
|
||||||
|
import type { TagEntity } from '@db/entities/TagEntity';
|
||||||
|
|
||||||
import type { UserManagementMailer } from '@/UserManagement/email';
|
import type { UserManagementMailer } from '@/UserManagement/email';
|
||||||
|
|
||||||
import type { Risk } from '@/security-audit/types';
|
import type { Risk } from '@/security-audit/types';
|
||||||
|
@ -57,6 +59,24 @@ export declare namespace ExecutionRequest {
|
||||||
type Delete = Get;
|
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 {
|
export declare namespace CredentialTypeRequest {
|
||||||
type Get = AuthenticatedRequest<{ credentialTypeName: string }, {}, {}, {}>;
|
type Get = AuthenticatedRequest<{ credentialTypeName: string }, {}, {}, {}>;
|
||||||
}
|
}
|
||||||
|
@ -74,6 +94,7 @@ export declare namespace WorkflowRequest {
|
||||||
offset?: number;
|
offset?: number;
|
||||||
workflowId?: number;
|
workflowId?: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
@ -82,6 +103,8 @@ export declare namespace WorkflowRequest {
|
||||||
type Delete = Get;
|
type Delete = Get;
|
||||||
type Update = AuthenticatedRequest<{ id: string }, {}, WorkflowEntity, {}>;
|
type Update = AuthenticatedRequest<{ id: string }, {}, WorkflowEntity, {}>;
|
||||||
type Activate = Get;
|
type Activate = Get;
|
||||||
|
type GetTags = Get;
|
||||||
|
type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare namespace UserRequest {
|
export declare namespace UserRequest {
|
||||||
|
|
|
@ -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'
|
|
@ -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'
|
|
@ -0,0 +1,6 @@
|
||||||
|
name: id
|
||||||
|
in: path
|
||||||
|
description: The ID of the tag.
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
|
@ -1,8 +1,12 @@
|
||||||
type: object
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- name
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: number
|
type: string
|
||||||
example: 12
|
readOnly: true
|
||||||
|
example: 2tUt1wbLX592XDdX
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
example: Production
|
example: Production
|
|
@ -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
|
103
packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts
Normal file
103
packages/cli/src/PublicApi/v1/handlers/tags/tags.handler.ts
Normal file
|
@ -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<express.Response> => {
|
||||||
|
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<express.Response> => {
|
||||||
|
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<express.Response> => {
|
||||||
|
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<express.Response> => {
|
||||||
|
const { offset = 0, limit = 100 } = req.query;
|
||||||
|
|
||||||
|
const query: FindManyOptions<TagEntity> = {
|
||||||
|
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<express.Response> => {
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
|
@ -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'
|
|
@ -44,6 +44,14 @@ get:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: test,production
|
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/limit.yml'
|
||||||
- $ref: '../../../../shared/spec/parameters/cursor.yml'
|
- $ref: '../../../../shared/spec/parameters/cursor.yml'
|
||||||
responses:
|
responses:
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
example: 2tUt1wbLX592XDdX
|
|
@ -45,5 +45,5 @@ properties:
|
||||||
tags:
|
tags:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: './tag.yml'
|
$ref: '../../../tags/spec/schemas/tag.yml'
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '../../../tags/spec/schemas/tag.yml'
|
|
@ -1,7 +1,8 @@
|
||||||
import type express from 'express';
|
import type express from 'express';
|
||||||
|
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import type { FindOptionsWhere } from '@n8n/typeorm';
|
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 { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
|
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
|
||||||
|
@ -20,6 +21,8 @@ import {
|
||||||
updateWorkflow,
|
updateWorkflow,
|
||||||
createWorkflow,
|
createWorkflow,
|
||||||
parseTagNames,
|
parseTagNames,
|
||||||
|
getWorkflowTags,
|
||||||
|
updateTags,
|
||||||
} from './workflows.service';
|
} from './workflows.service';
|
||||||
import { WorkflowService } from '@/workflows/workflow.service';
|
import { WorkflowService } from '@/workflows/workflow.service';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
|
@ -95,10 +98,11 @@ export = {
|
||||||
authorize(['global:owner', 'global:admin', 'global:member']),
|
authorize(['global:owner', 'global:admin', 'global:member']),
|
||||||
validCursor,
|
validCursor,
|
||||||
async (req: WorkflowRequest.GetAll, res: express.Response): Promise<express.Response> => {
|
async (req: WorkflowRequest.GetAll, res: express.Response): Promise<express.Response> => {
|
||||||
const { offset = 0, limit = 100, active = undefined, tags = undefined } = req.query;
|
const { offset = 0, limit = 100, active, tags, name } = req.query;
|
||||||
|
|
||||||
const where: FindOptionsWhere<WorkflowEntity> = {
|
const where: FindOptionsWhere<WorkflowEntity> = {
|
||||||
...(active !== undefined && { active }),
|
...(active !== undefined && { active }),
|
||||||
|
...(name !== undefined && { name: Like('%' + name.trim() + '%') }),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (['global:owner', 'global:admin'].includes(req.user.role)) {
|
if (['global:owner', 'global:admin'].includes(req.user.role)) {
|
||||||
|
@ -280,4 +284,59 @@ export = {
|
||||||
return res.json(sharedWorkflow.workflow);
|
return res.json(sharedWorkflow.workflow);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
getWorkflowTags: [
|
||||||
|
authorize(['global:owner', 'global:admin', 'global:member']),
|
||||||
|
async (req: WorkflowRequest.GetTags, res: express.Response): Promise<express.Response> => {
|
||||||
|
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<express.Response> => {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,10 +2,13 @@ import { Container } from 'typedi';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||||
|
import { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping';
|
||||||
import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow';
|
import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
||||||
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.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[] {
|
function insertIf(condition: boolean, elements: string[]): string[] {
|
||||||
return condition ? elements : [];
|
return condition ? elements : [];
|
||||||
|
@ -86,3 +89,29 @@ export async function updateWorkflow(workflowId: string, updateData: WorkflowEnt
|
||||||
export function parseTagNames(tags: string): string[] {
|
export function parseTagNames(tags: string): string[] {
|
||||||
return tags.split(',').map((tag) => tag.trim());
|
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<any> {
|
||||||
|
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 })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -26,6 +26,8 @@ tags:
|
||||||
description: Operations about workflows
|
description: Operations about workflows
|
||||||
- name: Credential
|
- name: Credential
|
||||||
description: Operations about credentials
|
description: Operations about credentials
|
||||||
|
- name: Tags
|
||||||
|
description: Operations about tags
|
||||||
- name: SourceControl
|
- name: SourceControl
|
||||||
description: Operations about source control
|
description: Operations about source control
|
||||||
|
|
||||||
|
@ -42,6 +44,10 @@ paths:
|
||||||
$ref: './handlers/executions/spec/paths/executions.yml'
|
$ref: './handlers/executions/spec/paths/executions.yml'
|
||||||
/executions/{id}:
|
/executions/{id}:
|
||||||
$ref: './handlers/executions/spec/paths/executions.id.yml'
|
$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:
|
/workflows:
|
||||||
$ref: './handlers/workflows/spec/paths/workflows.yml'
|
$ref: './handlers/workflows/spec/paths/workflows.yml'
|
||||||
/workflows/{id}:
|
/workflows/{id}:
|
||||||
|
@ -50,6 +56,8 @@ paths:
|
||||||
$ref: './handlers/workflows/spec/paths/workflows.id.activate.yml'
|
$ref: './handlers/workflows/spec/paths/workflows.id.activate.yml'
|
||||||
/workflows/{id}/deactivate:
|
/workflows/{id}/deactivate:
|
||||||
$ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml'
|
$ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml'
|
||||||
|
/workflows/{id}/tags:
|
||||||
|
$ref: './handlers/workflows/spec/paths/workflows.id.tags.yml'
|
||||||
/users:
|
/users:
|
||||||
$ref: './handlers/users/spec/paths/users.yml'
|
$ref: './handlers/users/spec/paths/users.yml'
|
||||||
/users/{id}:
|
/users/{id}:
|
||||||
|
|
|
@ -6,6 +6,8 @@ ExecutionId:
|
||||||
$ref: '../../../handlers/executions/spec/schemas/parameters/executionId.yml'
|
$ref: '../../../handlers/executions/spec/schemas/parameters/executionId.yml'
|
||||||
WorkflowId:
|
WorkflowId:
|
||||||
$ref: '../../../handlers/workflows/spec/schemas/parameters/workflowId.yml'
|
$ref: '../../../handlers/workflows/spec/schemas/parameters/workflowId.yml'
|
||||||
|
TagId:
|
||||||
|
$ref: '../../../handlers/tags/spec/schemas/parameters/tagId.yml'
|
||||||
IncludeData:
|
IncludeData:
|
||||||
$ref: '../../../handlers/executions/spec/schemas/parameters/includeData.yml'
|
$ref: '../../../handlers/executions/spec/schemas/parameters/includeData.yml'
|
||||||
UserIdentifier:
|
UserIdentifier:
|
||||||
|
|
|
@ -6,3 +6,5 @@ BadRequest:
|
||||||
$ref: './badRequest.yml'
|
$ref: './badRequest.yml'
|
||||||
Conflict:
|
Conflict:
|
||||||
$ref: './conflict.yml'
|
$ref: './conflict.yml'
|
||||||
|
Forbidden:
|
||||||
|
$ref: './forbidden.yml'
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
description: Forbidden
|
|
@ -7,7 +7,7 @@ Execution:
|
||||||
Node:
|
Node:
|
||||||
$ref: './../../../handlers/workflows/spec/schemas/node.yml'
|
$ref: './../../../handlers/workflows/spec/schemas/node.yml'
|
||||||
Tag:
|
Tag:
|
||||||
$ref: './../../../handlers/workflows/spec/schemas/tag.yml'
|
$ref: './../../../handlers/tags/spec/schemas/tag.yml'
|
||||||
Workflow:
|
Workflow:
|
||||||
$ref: './../../../handlers/workflows/spec/schemas/workflow.yml'
|
$ref: './../../../handlers/workflows/spec/schemas/workflow.yml'
|
||||||
WorkflowSettings:
|
WorkflowSettings:
|
||||||
|
|
|
@ -64,6 +64,12 @@ export class TagService {
|
||||||
}) as Promise<GetAllResult<T>>);
|
}) as Promise<GetAllResult<T>>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getById(id: string) {
|
||||||
|
return await this.tagRepository.findOneOrFail({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sort tags based on the order of the tag IDs in the request.
|
* Sort tags based on the order of the tag IDs in the request.
|
||||||
*/
|
*/
|
||||||
|
|
337
packages/cli/test/integration/publicApi/tags.test.ts
Normal file
337
packages/cli/test/integration/publicApi/tags.test.ts
Normal file
|
@ -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());
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,4 +1,5 @@
|
||||||
import type { SuperAgentTest } from 'supertest';
|
import type { SuperAgentTest } from 'supertest';
|
||||||
|
import config from '@/config';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
import type { INode } from 'n8n-workflow';
|
import type { INode } from 'n8n-workflow';
|
||||||
import { STARTING_NODES } from '@/constants';
|
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 () => {
|
test('should return all workflows for owner', async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
createWorkflow({}, owner),
|
createWorkflow({}, owner),
|
||||||
|
@ -1111,3 +1149,308 @@ describe('PUT /workflows/:id', () => {
|
||||||
expect(sharedWorkflow?.role).toEqual('workflow:owner');
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
Loading…
Reference in a new issue