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:
Omar Ajoue 2024-02-09 15:10:03 +00:00 committed by GitHub
parent 64b10d7f5c
commit a743a40376
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1131 additions and 6 deletions

View file

@ -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 {

View file

@ -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'

View file

@ -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'

View file

@ -0,0 +1,6 @@
name: id
in: path
description: The ID of the tag.
required: true
schema:
type: string

View file

@ -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

View file

@ -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

View 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' });
}
},
],
};

View file

@ -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'

View file

@ -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:

View file

@ -0,0 +1,10 @@
type: array
items:
type: object
additionalProperties: false
required:
- id
properties:
id:
type: string
example: 2tUt1wbLX592XDdX

View file

@ -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

View file

@ -0,0 +1,3 @@
type: array
items:
$ref: '../../../tags/spec/schemas/tag.yml'

View file

@ -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);
},
],
}; };

View file

@ -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 })),
);
});
}

View file

@ -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}:

View file

@ -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:

View file

@ -6,3 +6,5 @@ BadRequest:
$ref: './badRequest.yml' $ref: './badRequest.yml'
Conflict: Conflict:
$ref: './conflict.yml' $ref: './conflict.yml'
Forbidden:
$ref: './forbidden.yml'

View file

@ -0,0 +1 @@
description: Forbidden

View file

@ -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:

View file

@ -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.
*/ */

View 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());
});
});

View file

@ -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);
});
}
}
});
});