diff --git a/twake/backend/node/config/default.json b/twake/backend/node/config/default.json index 986258b2..3aaa8701 100644 --- a/twake/backend/node/config/default.json +++ b/twake/backend/node/config/default.json @@ -168,15 +168,11 @@ "tracker", "general", "user", - "applications-api", - "applications", "channels", "notifications", - "messages", "files", "workspaces", "console", - "previews", "counter", "statistics", "cron", diff --git a/twake/backend/node/src/core/platform/framework/logger.ts b/twake/backend/node/src/core/platform/framework/logger.ts index 8c0aa335..b07a2d93 100644 --- a/twake/backend/node/src/core/platform/framework/logger.ts +++ b/twake/backend/node/src/core/platform/framework/logger.ts @@ -8,13 +8,7 @@ export type TwakeLogger = pino.Logger; export const logger = pino({ name: "TwakeApp", level: config.get("level", "info") || "info", - prettyPrint: - process.env.NODE_ENV?.indexOf("test") > -1 - ? { - translateTime: "HH:MM:ss Z", - ignore: "pid,hostname,name", - } - : false, + prettyPrint: false, }); export const getLogger = (name?: string): TwakeLogger => diff --git a/twake/backend/node/src/services/applications/entities/application.search.ts b/twake/backend/node/src/services/applications/entities/application.search.ts new file mode 100644 index 00000000..669e0245 --- /dev/null +++ b/twake/backend/node/src/services/applications/entities/application.search.ts @@ -0,0 +1,33 @@ +import Application, { TYPE } from "./application"; + +export default { + index: TYPE, + source: (entity: Application) => { + return { + company_id: entity.company_id, + name: entity.identity.name, + description: entity.identity.description, + categories: entity.identity.categories, + compatibility: entity.identity.compatibility, + published: entity.publication.published, + created_at: entity.stats.created_at, + }; + }, + mongoMapping: { + text: { + name: "text", + description: "text", + }, + }, + esMapping: { + properties: { + company_id: { type: "keyword" }, + name: { type: "text", index_prefixes: { min_chars: 1 } }, + description: { type: "text" }, + categories: { type: "keyword" }, + compatibility: { type: "keyword" }, + published: { type: "boolean" }, + created_at: { type: "number" }, + }, + }, +}; diff --git a/twake/backend/node/src/services/applications/entities/application.ts b/twake/backend/node/src/services/applications/entities/application.ts new file mode 100644 index 00000000..21927414 --- /dev/null +++ b/twake/backend/node/src/services/applications/entities/application.ts @@ -0,0 +1,212 @@ +import { Type } from "class-transformer"; +import _, { merge } from "lodash"; +import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators"; +import search from "./application.search"; + +export const TYPE = "applications"; + +@Entity(TYPE, { + primaryKey: ["id"], + type: TYPE, + search, +}) +export default class Application { + @Type(() => String) + @Column("id", "timeuuid", { generator: "timeuuid" }) + id: string; + + @Type(() => String) + @Column("group_id", "timeuuid") + company_id: string; + + @Column("is_default", "boolean") + is_default: boolean; + + @Column("identity", "json") + identity: ApplicationIdentity; + + //This information is private to the application, make sure not to disclose it + @Column("api", "encoded_json") + api: ApplicationApi; + + @Column("access", "json") + access: ApplicationAccess; + + @Column("display", "json") + display: ApplicationDisplay; + + @Column("publication", "json") + publication: ApplicationPublication; + + @Column("stats", "json") + stats: ApplicationStatistics; + + getPublicObject(): PublicApplicationObject { + const i = _.pick( + this, + "id", + "company_id", + "is_default", + "identity", + "access", + "display", + "publication", + "stats", + ); + + i.is_default = !!i.is_default; + return i; + } + + getApplicationObject(): ApplicationObject { + const i = _.pick( + this, + "id", + "company_id", + "is_default", + "identity", + "access", + "display", + "publication", + "stats", + "api", + ); + + i.is_default = !!i.is_default; + return i; + } +} + +export type PublicApplicationObject = Pick< + Application, + "id" | "company_id" | "is_default" | "identity" | "access" | "display" | "publication" | "stats" +>; + +export type ApplicationObject = Pick< + Application, + | "id" + | "company_id" + | "is_default" + | "identity" + | "access" + | "display" + | "publication" + | "stats" + | "api" +>; + +export type ApplicationPrimaryKey = { id: string }; + +export function getInstance(message: Application): Application { + return merge(new Application(), message); +} + +export type ApplicationIdentity = { + code: string; + name: string; + icon: string; + description: string; + website: string; + categories: string[]; + compatibility: "twake"[]; + repository?: string; +}; + +export type ApplicationPublication = { + published: boolean; //Publication accepted // RO + requested: boolean; //Publication requested +}; + +export type ApplicationStatistics = { + created_at: number; // RO + updated_at: number; // RO + version: number; // RO +}; + +export type ApplicationApi = { + hooks_url: string; + allowed_ips: string; + private_key: string; // RO +}; + +type ApplicationScopes = + | "files" + | "applications" + | "workspaces" + | "users" + | "messages" + | "channels"; + +export type ApplicationAccess = { + read: ApplicationScopes[]; + write: ApplicationScopes[]; + delete: ApplicationScopes[]; + hooks: ApplicationScopes[]; +}; + +export type ApplicationDisplay = { + twake: { + files?: { + editor?: { + preview_url: string; //Open a preview inline (iframe) + edition_url: string; //Url to edit the file (full screen) + extensions?: string[]; //Main extensions app can read + // if file was created by the app, then the app is able to edit with or without extension + empty_files?: { + url: string; // "https://[...]/empty.docx"; + filename: string; // "Untitled.docx"; + name: string; // "Word Document"; + }[]; + }; + actions?: //List of action that can apply on a file + { + name: string; + id: string; + }[]; + }; + + //Chat plugin + chat?: { + input?: + | true + | { + icon?: string; //If defined replace original icon url of your app + type?: "file" | "call"; //To add in existing apps folder / default icon + }; + commands?: { + command: string; // my_app mycommand + description: string; + }[]; + actions?: //List of action that can apply on a message + { + name: string; + id: string; + }[]; + }; + + //Allow app to appear as a bot user in direct chat + direct?: + | true + | { + name?: string; + icon?: string; //If defined replace original icon url of your app + }; + + //Display app as a standalone application in a tab + tab?: + | { + url: string; + } + | true; + + //Display app as a standalone application on the left bar + standalone?: + | { + url: string; + } + | true; + + //Define where the app can be configured from + configuration?: ("global" | "channel")[]; + }; +}; diff --git a/twake/backend/node/src/services/applications/entities/company-application.ts b/twake/backend/node/src/services/applications/entities/company-application.ts new file mode 100644 index 00000000..7598e05f --- /dev/null +++ b/twake/backend/node/src/services/applications/entities/company-application.ts @@ -0,0 +1,40 @@ +import { Type } from "class-transformer"; +import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators"; +import { PublicApplicationObject } from "./application"; + +export const TYPE = "group_app"; + +@Entity(TYPE, { + primaryKey: [["group_id"], "app_id", "id"], + type: TYPE, +}) +export default class CompanyApplication { + @Type(() => String) + @Column("group_id", "timeuuid") + company_id: string; + + @Type(() => String) + @Column("app_id", "timeuuid") + application_id: string; + + @Type(() => String) + @Column("id", "timeuuid", { generator: "timeuuid" }) + id: string; + + @Column("created_at", "number") + created_at: number; + + @Type(() => String) + @Column("created_by", "string") + created_by: string; //Will be the default delegated user when doing actions on Twake +} + +export type CompanyApplicationPrimaryKey = Pick< + CompanyApplication, + "company_id" | "application_id" | "id" +>; + +export class CompanyApplicationWithApplication extends CompanyApplication { + //Not in database but attached to this object + application?: PublicApplicationObject; +} diff --git a/twake/backend/node/src/services/applications/index.ts b/twake/backend/node/src/services/applications/index.ts new file mode 100644 index 00000000..7c73c6a8 --- /dev/null +++ b/twake/backend/node/src/services/applications/index.ts @@ -0,0 +1,24 @@ +import { Prefix, TwakeService } from "../../core/platform/framework"; +import WebServerAPI from "../../core/platform/services/webserver/provider"; +import web from "./web"; + +@Prefix("/internal/services/applications/v1") +export default class ApplicationsService extends TwakeService { + version = "1"; + name = "applications"; + + public async doInit(): Promise { + const fastify = this.context.getProvider("webserver").getServer(); + fastify.register((instance, _opts, next) => { + web(instance, { prefix: this.prefix }); + next(); + }); + + return this; + } + + // TODO: remove + api(): undefined { + return undefined; + } +} diff --git a/twake/backend/node/src/services/applications/realtime.ts b/twake/backend/node/src/services/applications/realtime.ts new file mode 100644 index 00000000..074f8a3e --- /dev/null +++ b/twake/backend/node/src/services/applications/realtime.ts @@ -0,0 +1,13 @@ +import { WebsocketMetadata } from "../../utils/types"; + +export function getCompanyApplicationRooms(companyId: string): WebsocketMetadata[] { + return [ + { + room: getCompanyApplicationRoom(companyId), + }, + ]; +} + +export function getCompanyApplicationRoom(companyApplicationId: string): string { + return `/company-application/${companyApplicationId}`; +} diff --git a/twake/backend/node/src/services/applications/services/applications.ts b/twake/backend/node/src/services/applications/services/applications.ts new file mode 100644 index 00000000..4eecdd8f --- /dev/null +++ b/twake/backend/node/src/services/applications/services/applications.ts @@ -0,0 +1,143 @@ +import Application, { + ApplicationPrimaryKey, + getInstance as getApplicationInstance, + PublicApplicationObject, + TYPE, +} from "../entities/application"; +import Repository from "../../../core/platform/services/database/services/orm/repository/repository"; +import { Initializable, logger, TwakeServiceProvider } from "../../../core/platform/framework"; +import { + DeleteResult, + ExecutionContext, + ListResult, + OperationType, + Pagination, + SaveResult, +} from "../../../core/platform/framework/api/crud-service"; +import SearchRepository from "../../../core/platform/services/search/repository"; +import assert from "assert"; + +import gr from "../../global-resolver"; +import { InternalToHooksProcessor } from "./internal-event-to-hooks"; + +export class ApplicationServiceImpl implements TwakeServiceProvider, Initializable { + version: "1"; + repository: Repository; + searchRepository: SearchRepository; + + async init(): Promise { + try { + this.searchRepository = gr.platformServices.search.getRepository( + TYPE, + Application, + ); + this.repository = await gr.database.getRepository(TYPE, Application); + } catch (err) { + console.log(err); + logger.error("Error while initializing applications service"); + } + + gr.platformServices.messageQueue.processor.addHandler(new InternalToHooksProcessor()); + + return this; + } + + async get(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise { + return await this.repository.findOne(pk, {}, context); + } + + async list( + pagination: Pagination, + options?: { search?: string }, + context?: ExecutionContext, + ): Promise> { + let entities: ListResult; + if (options.search) { + entities = await this.searchRepository.search( + {}, + { + pagination, + $text: { + $search: options.search, + }, + }, + context, + ); + } else { + entities = await this.repository.find({}, { pagination }, context); + } + entities.filterEntities(app => app.publication.published); + + const applications = entities + .getEntities() + .filter(app => app) + .map(app => app.getPublicObject()); + return new ListResult(entities.type, applications, entities.nextPage); + } + + async listUnpublished(context: ExecutionContext): Promise { + const entities = await this.repository.find({}, {}, context); + entities.filterEntities(app => !app.publication.published); + return entities.getEntities(); + } + + async listDefaults(context: ExecutionContext): Promise> { + const entities = []; + + let page: Pagination = { limitStr: "100" }; + do { + const applicationListResult = await this.repository.find({}, { pagination: page }, context); + page = applicationListResult.nextPage as Pagination; + applicationListResult.filterEntities(app => app.publication.published && app.is_default); + + for (const application of applicationListResult.getEntities()) { + if (application) entities.push(application.getPublicObject()); + } + } while (page.page_token); + + return new ListResult(TYPE, entities); + } + + async save( + item: Application, + options?: SaveOptions, + context?: ExecutionContext, + ): Promise> { + assert(item.company_id, "company_id is not defined"); + + try { + const entity = getApplicationInstance(item); + await this.repository.save(entity, context); + return new SaveResult("application", entity, OperationType.UPDATE); + } catch (e) { + throw e; + } + } + + async delete( + pk: ApplicationPrimaryKey, + context?: ExecutionContext, + ): Promise> { + const entity = await this.get(pk, context); + await this.repository.remove(entity, context); + return new DeleteResult("application", entity, true); + } + + async publish(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise { + const entity = await this.get(pk, context); + if (!entity) { + throw new Error("Entity not found"); + } + entity.publication.published = true; + await this.repository.save(entity, context); + } + + async unpublish(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise { + const entity = await this.get(pk, context); + if (!entity) { + throw new Error("Entity not found"); + } + entity.publication.published = false; + await this.repository.save(entity, context); + } +} diff --git a/twake/backend/node/src/services/applications/services/company-applications.ts b/twake/backend/node/src/services/applications/services/company-applications.ts new file mode 100644 index 00000000..5ae82621 --- /dev/null +++ b/twake/backend/node/src/services/applications/services/company-applications.ts @@ -0,0 +1,188 @@ +import CompanyApplication, { + CompanyApplicationPrimaryKey, + CompanyApplicationWithApplication, + TYPE, +} from "../entities/company-application"; +import Repository from "../../../core/platform/services/database/services/orm/repository/repository"; +import { + Initializable, + logger, + RealtimeDeleted, + RealtimeSaved, + TwakeServiceProvider, +} from "../../../core/platform/framework"; +import { + DeleteResult, + ListResult, + OperationType, + Paginable, + Pagination, + SaveResult, +} from "../../../core/platform/framework/api/crud-service"; +import { CompanyExecutionContext } from "../web/types"; +import { getCompanyApplicationRoom } from "../realtime"; +import gr from "../../global-resolver"; + +export class CompanyApplicationServiceImpl implements TwakeServiceProvider, Initializable { + version: "1"; + repository: Repository; + + async init(): Promise { + try { + this.repository = await gr.database.getRepository( + TYPE, + CompanyApplication, + ); + } catch (err) { + console.log(err); + logger.error("Error while initializing applications service"); + } + return this; + } + + // TODO: remove logic from context + async get( + pk: Pick & { id?: string }, + context?: CompanyExecutionContext, + ): Promise { + const companyApplication = await this.repository.findOne( + { + group_id: context ? context.company.id : pk.company_id, + app_id: pk.application_id, + }, + {}, + context, + ); + + const application = await gr.services.applications.marketplaceApps.get( + { + id: pk.application_id, + }, + context, + ); + + return { + ...companyApplication, + application: application, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + @RealtimeSaved((companyApplication, _context) => { + return [ + { + room: getCompanyApplicationRoom(companyApplication.id), + resource: companyApplication, + }, + ]; + }) + async save( + item: Pick, + _?: SaveOptions, + context?: CompanyExecutionContext, + ): Promise> { + if (!context?.user?.id && !context?.user?.server_request) { + throw new Error("Only an user of a company can add an application to a company."); + } + + let operation = OperationType.UPDATE; + let companyApplication = await this.repository.findOne( + { + group_id: context?.company.id, + app_id: item.application_id, + }, + {}, + context, + ); + if (!companyApplication) { + operation = OperationType.CREATE; + + companyApplication = new CompanyApplication(); + companyApplication.company_id = context.company.id; + companyApplication.application_id = item.application_id; + companyApplication.created_at = new Date().getTime(); + companyApplication.created_by = context?.user?.id || ""; + + await this.repository.save(companyApplication, context); + } + + return new SaveResult(TYPE, companyApplication, operation); + } + + async initWithDefaultApplications( + companyId: string, + context: CompanyExecutionContext, + ): Promise { + const defaultApps = await gr.services.applications.marketplaceApps.listDefaults(context); + for (const defaultApp of defaultApps.getEntities()) { + await this.save({ company_id: companyId, application_id: defaultApp.id }, {}, context); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + @RealtimeDeleted((companyApplication, _context) => { + return [ + { + room: getCompanyApplicationRoom(companyApplication.id), + resource: companyApplication, + }, + ]; + }) + async delete( + pk: CompanyApplicationPrimaryKey, + context?: CompanyExecutionContext, + ): Promise> { + const companyApplication = await this.repository.findOne( + { + group_id: context.company.id, + app_id: pk.application_id, + }, + {}, + context, + ); + + let deleted = false; + if (companyApplication) { + this.repository.remove(companyApplication, context); + deleted = true; + } + + return new DeleteResult(TYPE, companyApplication, deleted); + } + + async list( + pagination: Paginable, + options?: ListOptions, + context?: CompanyExecutionContext, + ): Promise> { + const companyApplications = await this.repository.find( + { + group_id: context.company.id, + }, + { pagination: Pagination.fromPaginable(pagination) }, + context, + ); + + const applications = []; + + for (const companyApplication of companyApplications.getEntities()) { + const application = await gr.services.applications.marketplaceApps.get( + { + id: companyApplication.application_id, + }, + context, + ); + if (application) + applications.push({ + ...companyApplication, + application: application, + }); + } + + return new ListResult( + TYPE, + applications, + companyApplications.nextPage, + ); + } +} diff --git a/twake/backend/node/src/services/applications/services/hooks.ts b/twake/backend/node/src/services/applications/services/hooks.ts new file mode 100644 index 00000000..d34a5f15 --- /dev/null +++ b/twake/backend/node/src/services/applications/services/hooks.ts @@ -0,0 +1,96 @@ +import Application from "../entities/application"; +import Repository from "../../../core/platform/services/database/services/orm/repository/repository"; +import { + Initializable, + logger as log, + TwakeServiceProvider, +} from "../../../core/platform/framework"; +import { CrudException, ExecutionContext } from "../../../core/platform/framework/api/crud-service"; +import SearchRepository from "../../../core/platform/services/search/repository"; +import axios from "axios"; +import * as crypto from "crypto"; +import { isObject } from "lodash"; +import gr from "../../global-resolver"; + +export class ApplicationHooksService implements TwakeServiceProvider, Initializable { + version: "1"; + repository: Repository; + searchRepository: SearchRepository; + + async init() { + return this; + } + + async notifyApp( + application_id: string, + connection_id: string, + user_id: string, + type: string, + name: string, + content: any, + company_id: string, + workspace_id: string, + context: ExecutionContext, + ): Promise { + const app = await gr.services.applications.marketplaceApps.get({ id: application_id }, context); + if (!app) { + throw CrudException.notFound("Application not found"); + } + + if (!app.api.hooks_url) { + throw CrudException.badRequest("Application hooks_url is not defined"); + } + + const payload = { + type, + name, + content, + connection_id: connection_id, + user_id: user_id, + company_id, + workspace_id, + }; + + const signature = crypto + .createHmac("sha256", app.api.private_key) + .update(JSON.stringify(payload)) + .digest("hex"); + + return await axios + .post(app.api.hooks_url, payload, { + headers: { + "Content-Type": "application/json", + "X-Twake-Signature": signature, + }, + }) + + .then(({ data }) => data) + .catch(e => { + log.error(e.message); + const r = e.response; + + if (!r) { + throw CrudException.badGateway("Can't connect remote application"); + } + + let msg = r.data; + + if (isObject(msg)) { + // parse typical responses + if (r.data.message) { + msg = r.data.message; + } else if (r.data.error) { + msg = r.data.error; + } else { + msg = JSON.stringify(r.data); + } + } + + if (r.status == 403) { + throw CrudException.forbidden(msg); + } else { + throw CrudException.badRequest(msg); + } + }); + } +} diff --git a/twake/backend/node/src/services/applications/services/internal-event-to-hooks.ts b/twake/backend/node/src/services/applications/services/internal-event-to-hooks.ts new file mode 100644 index 00000000..77fe8d74 --- /dev/null +++ b/twake/backend/node/src/services/applications/services/internal-event-to-hooks.ts @@ -0,0 +1,64 @@ +import { logger } from "../../../core/platform/framework"; +import { HookType } from "../../applications-api/types"; +import { MessageQueueHandler } from "../../../core/platform/services/message-queue/api"; +import { MessageHook } from "../../messages/types"; +import gr from "../../global-resolver"; +import { ExecutionContext } from "../../../core/platform/framework/api/crud-service"; + +export class InternalToHooksProcessor implements MessageQueueHandler { + readonly topics = { + in: "application:hook:message", + }; + + readonly options = { + unique: true, + ack: true, + queue: "application:hook:message:consumer1", + }; + + readonly name = "Application::InternalToHooksProcessor"; + + validate(_: HookType): boolean { + return true; + } + + async process(message: HookType, context?: ExecutionContext): Promise { + logger.debug(`${this.name} - Receive hook of type ${message.type}`); + + const application = await gr.services.applications.marketplaceApps.get( + { + id: message.application_id, + }, + context, + ); + + //TODO Check application access rights (hooks) + const _access = application.access; + + // Check application still exists in the company + if ( + !(await gr.services.applications.companyApps.get({ + company_id: message.company_id, + application_id: message.application_id, + id: undefined, + })) + ) { + logger.error( + `${this.name} - Application ${message.application_id} not found in company ${message.company_id}`, + ); + return; + } + + await gr.services.applications.hooks.notifyApp( + message.application_id, + null, + null, + "hook", + null, + { message }, + null, + null, + context, + ); + } +} diff --git a/twake/backend/node/src/services/applications/web/controllers/applications.ts b/twake/backend/node/src/services/applications/web/controllers/applications.ts new file mode 100644 index 00000000..11cbaa2d --- /dev/null +++ b/twake/backend/node/src/services/applications/web/controllers/applications.ts @@ -0,0 +1,296 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { CrudController } from "../../../../core/platform/services/webserver/types"; +import { + PaginationQueryParameters, + ResourceCreateResponse, + ResourceDeleteResponse, + ResourceGetResponse, + ResourceListResponse, + ResourceUpdateResponse, +} from "../../../../utils/types"; +import Application, { + ApplicationObject, + PublicApplicationObject, +} from "../../entities/application"; +import { + CrudException, + ExecutionContext, + Pagination, +} from "../../../../core/platform/framework/api/crud-service"; +import _ from "lodash"; +import { randomBytes } from "crypto"; +import { ApplicationEventRequestBody } from "../types"; +import { logger as log } from "../../../../core/platform/framework"; +import { hasCompanyAdminLevel } from "../../../../utils/company"; +import gr from "../../../global-resolver"; +import config from "../../../../core/config"; +import axios from "axios"; + +export class ApplicationController + implements + CrudController< + ResourceGetResponse, + ResourceUpdateResponse, + ResourceListResponse, + ResourceDeleteResponse + > +{ + async get( + request: FastifyRequest<{ Params: { application_id: string } }>, + ): Promise> { + const context = getExecutionContext(request); + + const entity = await gr.services.applications.marketplaceApps.get( + { + id: request.params.application_id, + }, + context, + ); + + const companyUser = await gr.services.companies.getCompanyUser( + { id: entity.company_id }, + { id: context.user.id }, + ); + + const isAdmin = companyUser && companyUser.role == "admin"; + + return { + resource: isAdmin ? entity.getApplicationObject() : entity.getPublicObject(), + }; + } + + async list( + request: FastifyRequest<{ + Querystring: PaginationQueryParameters & { search: string }; + }>, + ): Promise> { + const entities = await gr.services.applications.marketplaceApps.list(new Pagination(), { + search: request.query.search, + }); + return { + resources: entities.getEntities(), + next_page_token: entities.nextPage.page_token, + }; + } + + async save( + request: FastifyRequest<{ + Params: { application_id: string }; + Body: { resource: Application }; + }>, + _reply: FastifyReply, + ): Promise> { + const context = getExecutionContext(request); + + try { + const app = request.body.resource; + const now = new Date().getTime(); + const pluginsEndpoint = config.get("plugins.api"); + + let entity: Application; + + if (request.params.application_id) { + entity = await gr.services.applications.marketplaceApps.get( + { + id: request.params.application_id, + }, + context, + ); + + if (!entity) { + throw CrudException.notFound("Application not found"); + } + + entity.publication.requested = app.publication.requested; + if (app.publication.requested === false) { + entity.publication.published = false; + } + + if (entity.publication.published) { + if ( + !_.isEqual( + _.pick(entity, "identity", "api", "access", "display"), + _.pick(app, "identity", "api", "access", "display"), + ) + ) { + throw CrudException.badRequest( + "You can't update applications details while it published", + ); + } + } + + entity.identity = app.identity; + entity.api.hooks_url = app.api.hooks_url; + entity.api.allowed_ips = app.api.allowed_ips; + entity.access = app.access; + entity.display = app.display; + + entity.stats.updated_at = now; + entity.stats.version++; + + const res = await gr.services.applications.marketplaceApps.save(entity); + entity = res.entity; + } else { + // INSERT + + app.is_default = false; + app.publication.published = false; + app.api.private_key = randomBytes(32).toString("base64"); + + app.stats = { + created_at: now, + updated_at: now, + version: 0, + }; + + const res = await gr.services.applications.marketplaceApps.save(app); + entity = res.entity; + } + + // SYNC PLUGINS + if (app.identity.repository) { + try { + axios + .post( + `${pluginsEndpoint}/add`, + { + gitRepo: app.identity.repository, + pluginId: entity.getApplicationObject().id, + pluginSecret: entity.getApplicationObject().api.private_key, + }, + { + headers: { + "Content-Type": "application/json", + }, + }, + ) + .then(response => { + log.info(response.data); + }) + .catch(error => { + log.error(error); + }); + } catch (error) { + console.error(error); + } + } + + return { + resource: entity.getApplicationObject(), + }; + } catch (e) { + log.error(e); + throw e; + } + } + + async delete( + request: FastifyRequest<{ Params: { application_id: string } }>, + reply: FastifyReply, + ): Promise { + const context = getExecutionContext(request); + + const application = await gr.services.applications.marketplaceApps.get( + { + id: request.params.application_id, + }, + context, + ); + + const compUser = await gr.services.companies.getCompanyUser( + { id: application.company_id }, + { id: context.user.id }, + ); + if (!compUser || !hasCompanyAdminLevel(compUser.role)) { + throw CrudException.forbidden("You don't have the rights to delete this application"); + } + + const deleteResult = await gr.services.applications.marketplaceApps.delete( + { + id: request.params.application_id, + }, + context, + ); + + if (deleteResult.deleted) { + reply.code(204); + + return { + status: "success", + }; + } + + return { + status: "error", + }; + } + + async event( + request: FastifyRequest<{ + Body: ApplicationEventRequestBody; + Params: { application_id: string }; + }>, + _reply: FastifyReply, + ): Promise> { + const context = getExecutionContext(request); + + const content = request.body.data; + + const applicationEntity = await gr.services.applications.marketplaceApps.get( + { + id: request.params.application_id, + }, + context, + ); + + if (!applicationEntity) { + throw CrudException.notFound("Application not found"); + } + + const companyUser = gr.services.companies.getCompanyUser( + { id: request.body.company_id }, + { id: context.user.id }, + ); + + if (!companyUser) { + throw CrudException.badRequest( + "You cannot send event to an application from another company", + ); + } + + const applicationInCompany = await gr.services.applications.companyApps.get({ + company_id: request.body.company_id, + application_id: request.params.application_id, + id: undefined, + }); + + if (!applicationInCompany) { + throw CrudException.badRequest("Application isn't installed in this company"); + } + + const hookResponse = await gr.services.applications.hooks.notifyApp( + request.params.application_id, + request.body.connection_id, + context.user.id, + request.body.type, + request.body.name, + content, + request.body.company_id, + request.body.workspace_id, + context, + ); + + return { + resource: hookResponse, + }; + } +} + +function getExecutionContext(request: FastifyRequest): ExecutionContext { + return { + user: request.currentUser, + url: request.url, + method: request.routerMethod, + transport: "http", + }; +} diff --git a/twake/backend/node/src/services/applications/web/controllers/company-applications.ts b/twake/backend/node/src/services/applications/web/controllers/company-applications.ts new file mode 100644 index 00000000..26542e39 --- /dev/null +++ b/twake/backend/node/src/services/applications/web/controllers/company-applications.ts @@ -0,0 +1,119 @@ +import { FastifyReply, FastifyRequest } from "fastify"; + +import { + PaginationQueryParameters, + ResourceDeleteResponse, + ResourceGetResponse, + ResourceListResponse, + ResourceUpdateResponse, +} from "../../../../utils/types"; +import { PublicApplicationObject } from "../../entities/application"; +import { CompanyExecutionContext } from "../types"; +import { CrudController } from "../../../../core/platform/services/webserver/types"; +import { getCompanyApplicationRooms } from "../../realtime"; +import gr from "../../../global-resolver"; + +export class CompanyApplicationController + implements + CrudController< + ResourceGetResponse, + ResourceUpdateResponse, + ResourceListResponse, + ResourceDeleteResponse + > +{ + async get( + request: FastifyRequest<{ Params: { company_id: string; application_id: string } }>, + ): Promise> { + const context = getCompanyExecutionContext(request); + const resource = await gr.services.applications.companyApps.get( + { + application_id: request.params.application_id, + company_id: context.company.id, + id: undefined, + }, + context, + ); + return { + resource: resource?.application, + }; + } + + async list( + request: FastifyRequest<{ + Params: { company_id: string }; + Querystring: PaginationQueryParameters & { search: string }; + }>, + ): Promise> { + const context = getCompanyExecutionContext(request); + const resources = await gr.services.applications.companyApps.list( + request.query, + { search: request.query.search }, + context, + ); + + return { + resources: resources.getEntities().map(ca => ca.application), + next_page_token: resources.nextPage.page_token, + websockets: + gr.platformServices.realtime.sign( + getCompanyApplicationRooms(request.params.company_id), + context.user.id, + ) || [], + }; + } + + async save( + request: FastifyRequest<{ + Params: { company_id: string; application_id: string }; + Body: PublicApplicationObject; + }>, + ): Promise> { + const context = getCompanyExecutionContext(request); + + const resource = await gr.services.applications.companyApps.save( + { application_id: request.params.application_id, company_id: context.company.id }, + {}, + context, + ); + + const app = await gr.services.applications.companyApps.get(resource.entity); + + return { + resource: app.application, + }; + } + + async delete( + request: FastifyRequest<{ Params: { company_id: string; application_id: string } }>, + _reply: FastifyReply, + ): Promise { + const context = getCompanyExecutionContext(request); + const resource = await gr.services.applications.companyApps.delete( + { + application_id: request.params.application_id, + company_id: context.company.id, + id: undefined, + }, + context, + ); + return { + status: resource.deleted ? "success" : "error", + }; + } +} + +function getCompanyExecutionContext( + request: FastifyRequest<{ + Params: { company_id: string }; + }>, +): CompanyExecutionContext { + return { + user: request.currentUser, + company: { id: request.params.company_id }, + url: request.url, + method: request.routerMethod, + reqId: request.id, + transport: "http", + }; +} diff --git a/twake/backend/node/src/services/applications/web/controllers/index.ts b/twake/backend/node/src/services/applications/web/controllers/index.ts new file mode 100644 index 00000000..ef52722d --- /dev/null +++ b/twake/backend/node/src/services/applications/web/controllers/index.ts @@ -0,0 +1 @@ +export * from "./applications"; diff --git a/twake/backend/node/src/services/applications/web/index.ts b/twake/backend/node/src/services/applications/web/index.ts new file mode 100644 index 00000000..3c2df979 --- /dev/null +++ b/twake/backend/node/src/services/applications/web/index.ts @@ -0,0 +1,12 @@ +import { FastifyInstance, FastifyRegisterOptions } from "fastify"; +import routes from "./routes"; + +export default ( + fastify: FastifyInstance, + options: FastifyRegisterOptions<{ + prefix: string; + }>, +): void => { + fastify.log.debug("Configuring /internal/services/applications/v1 routes"); + fastify.register(routes, options); +}; diff --git a/twake/backend/node/src/services/applications/web/routes.ts b/twake/backend/node/src/services/applications/web/routes.ts new file mode 100644 index 00000000..3d5835c7 --- /dev/null +++ b/twake/backend/node/src/services/applications/web/routes.ts @@ -0,0 +1,161 @@ +import { FastifyInstance, FastifyPluginCallback, FastifyRequest } from "fastify"; +import { ApplicationController } from "./controllers/applications"; +import { CompanyApplicationController } from "./controllers/company-applications"; + +import Application from "../entities/application"; +import { applicationEventHookSchema, applicationPostSchema } from "./schemas"; +import { logger as log } from "../../../core/platform/framework"; +import { checkUserBelongsToCompany, hasCompanyAdminLevel } from "../../../utils/company"; +import gr from "../../global-resolver"; + +const applicationsUrl = "/applications"; +const companyApplicationsUrl = "/companies/:company_id/applications"; + +const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => { + const applicationController = new ApplicationController(); + const companyApplicationController = new CompanyApplicationController(); + + const adminCheck = async ( + request: FastifyRequest<{ + Body: { resource: Application }; + Params: { application_id: string }; + }>, + ) => { + try { + let companyId: string = request.body?.resource?.company_id; + + if (request.params.application_id) { + const application = await gr.services.applications.marketplaceApps.get( + { + id: request.params.application_id, + }, + undefined, + ); + + if (!application) { + throw fastify.httpErrors.notFound("Application is not defined"); + } + + companyId = application.company_id; + } + + const userId = request.currentUser.id; + + if (!companyId) { + throw fastify.httpErrors.forbidden(`Company ${companyId} not found`); + } + + const companyUser = await checkUserBelongsToCompany(userId, companyId); + + if (!hasCompanyAdminLevel(companyUser.role)) { + throw fastify.httpErrors.forbidden("You must be an admin of this company"); + } + } catch (e) { + log.error(e); + throw e; + } + }; + + /** + * Applications collection + * Marketplace of applications + */ + + //Get and search list of applications in the marketplace + fastify.route({ + method: "GET", + url: `${applicationsUrl}`, + preValidation: [fastify.authenticate], + // schema: applicationGetSchema, + handler: applicationController.list.bind(applicationController), + }); + + //Get a single application in the marketplace + fastify.route({ + method: "GET", + url: `${applicationsUrl}/:application_id`, + preValidation: [fastify.authenticate], + // schema: applicationGetSchema, + handler: applicationController.get.bind(applicationController), + }); + + //Create application (must be my company application and I must be company admin) + fastify.route({ + method: "POST", + url: `${applicationsUrl}`, + preHandler: [adminCheck], + preValidation: [fastify.authenticate], + schema: applicationPostSchema, + handler: applicationController.save.bind(applicationController), + }); + + //Edit application (must be my company application and I must be company admin) + fastify.route({ + method: "POST", + url: `${applicationsUrl}/:application_id`, + preHandler: [adminCheck], + preValidation: [fastify.authenticate], + schema: applicationPostSchema, + handler: applicationController.save.bind(applicationController), + }); + + // Delete application (must be my company application and I must be company admin) + fastify.route({ + method: "DELETE", + url: `${applicationsUrl}/:application_id`, + preHandler: [adminCheck], + preValidation: [fastify.authenticate], + handler: applicationController.delete.bind(applicationController), + }); + + /** + * Company applications collection + * Company-wide available applications + * (must be my company application and I must be company admin) + */ + + //Get list of applications for a company + fastify.route({ + method: "GET", + url: `${companyApplicationsUrl}`, + preValidation: [fastify.authenticate], + handler: companyApplicationController.list.bind(companyApplicationController), + }); + + //Get one application of a company + fastify.route({ + method: "GET", + url: `${companyApplicationsUrl}/:application_id`, + preValidation: [fastify.authenticate], + handler: companyApplicationController.get.bind(companyApplicationController), + }); + + //Remove an application from a company + fastify.route({ + method: "DELETE", + url: `${companyApplicationsUrl}/:application_id`, + preValidation: [fastify.authenticate], + handler: companyApplicationController.delete.bind(companyApplicationController), + }); + + //Add an application to the company + fastify.route({ + method: "POST", + url: `${companyApplicationsUrl}/:application_id`, + preValidation: [fastify.authenticate], + handler: companyApplicationController.save.bind(companyApplicationController), + }); + + //Application event triggered by a user + fastify.route({ + method: "POST", + url: `${applicationsUrl}/:application_id/event`, + preValidation: [fastify.authenticate], + schema: applicationEventHookSchema, + handler: applicationController.event.bind(applicationController), + }); + + next(); +}; + +export default routes; diff --git a/twake/backend/node/src/services/applications/web/schemas.ts b/twake/backend/node/src/services/applications/web/schemas.ts new file mode 100644 index 00000000..996cea70 --- /dev/null +++ b/twake/backend/node/src/services/applications/web/schemas.ts @@ -0,0 +1,129 @@ +export const applicationsSchema = { + type: "object", + properties: {}, +}; + +const applicationIdentity = { + type: "object", + properties: { + code: { type: "string" }, + name: { type: "string" }, + icon: { type: "string" }, + description: { type: "string" }, + website: { type: "string" }, + categories: { type: "array", items: { type: "string" } }, + compatibility: { type: "array", items: { type: "string" } }, + }, + required: ["code", "name", "icon", "description", "website", "categories", "compatibility"], +}; + +const applicationAccess = { + type: "object", + properties: { + read: { type: "array", items: { type: "string" } }, + write: { type: "array", items: { type: "string" } }, + delete: { type: "array", items: { type: "string" } }, + hooks: { type: "array", items: { type: "string" } }, + }, + required: ["read", "write", "delete", "hooks"], +}; + +const requestApplicationPublication = { + type: "object", + properties: { + requested: { type: "boolean" }, + }, + required: ["requested"], +}; + +const responseApplicationPublication = { + type: "object", + properties: { + published: { type: "boolean" }, + requested: { type: "boolean" }, + }, + required: ["requested", "published"], +}; + +const applicationStats = { + type: "object", + properties: { + created_at: { type: "number" }, + updated_at: { type: "number" }, + version: { type: "number" }, + }, + required: ["created_at", "updated_at", "version"], +}; + +const apiObject = { + type: "object", + properties: { + hooks_url: { type: "string" }, + allowed_ips: { type: "string" }, + private_key: { type: "string" }, + }, + required: ["hooks_url", "allowed_ips"], +}; + +const requestApplicationObject = { + type: "object", + properties: { + company_id: { type: "string" }, + identity: applicationIdentity, + access: applicationAccess, + display: {}, + api: apiObject, + publication: requestApplicationPublication, + }, + required: ["company_id", "identity", "access", "display", "api", "publication"], + additionalProperties: false, +}; + +const responseApplicationObject = { + type: "object", + properties: { + id: { type: "string" }, + is_default: { type: "boolean" }, + company_id: { type: "string" }, + identity: applicationIdentity, + access: applicationAccess, + display: {}, + publication: responseApplicationPublication, + api: apiObject, + stats: applicationStats, + }, + required: [ + "id", + "is_default", + "company_id", + "identity", + "access", + "display", + "publication", + "stats", + ], + additionalProperties: false, +}; + +export const applicationPostSchema = { + body: { type: "object", properties: { resource: requestApplicationObject } }, + response: { + "2xx": { + resource: responseApplicationObject, + }, + }, +}; + +export const applicationEventHookSchema = { + body: { + type: "object", + properties: { + company_id: { type: "string" }, + workspace_id: { type: "string" }, + type: { type: "string" }, + name: { type: "string" }, + content: {}, + }, + required: ["company_id", "workspace_id", "type", "content"], + }, +}; diff --git a/twake/backend/node/src/services/applications/web/types.ts b/twake/backend/node/src/services/applications/web/types.ts new file mode 100644 index 00000000..5a60f3b3 --- /dev/null +++ b/twake/backend/node/src/services/applications/web/types.ts @@ -0,0 +1,15 @@ +import { ExecutionContext } from "../../../core/platform/framework/api/crud-service"; + +export interface CompanyExecutionContext extends ExecutionContext { + company: { id: string }; +} + +export interface ApplicationEventRequestBody { + company_id: string; + workspace_id: string; + connection_id: string; + type: string; + name?: string; + content: any; + data: any; +} diff --git a/twake/backend/node/test/e2e/documents/assets/test.txt b/twake/backend/node/test/e2e/documents/assets/test.txt new file mode 100644 index 00000000..9daeafb9 --- /dev/null +++ b/twake/backend/node/test/e2e/documents/assets/test.txt @@ -0,0 +1 @@ +test diff --git a/twake/backend/node/test/e2e/documents/documents-tab.spec.ts b/twake/backend/node/test/e2e/documents/documents-tab.spec.ts new file mode 100644 index 00000000..e9d6c84e --- /dev/null +++ b/twake/backend/node/test/e2e/documents/documents-tab.spec.ts @@ -0,0 +1,183 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "@jest/globals"; +import { deserialize } from "class-transformer"; +import { AccessInformation } from "../../../src/services/documents/entities/drive-file"; +import { init, TestPlatform } from "../setup"; +import { TestDbService } from "../utils.prepare.db"; +import { e2e_createDocument, e2e_getDocument } from "./utils"; + +const url = "/internal/services/documents/v1"; + +describe("the Drive Twake tabs feature", () => { + let platform: TestPlatform; + + class DriveFileMockClass { + id: string; + name: string; + size: number; + added: string; + parent_id: string; + access_info: AccessInformation; + } + + class DriveItemDetailsMockClass { + path: string[]; + item: DriveFileMockClass; + children: DriveFileMockClass[]; + versions: Record[]; + } + + beforeEach(async () => { + platform = await init({ + services: [ + "webserver", + "database", + "applications", + "search", + "storage", + "message-queue", + "user", + "search", + "files", + "websocket", + "messages", + "auth", + "realtime", + "channels", + "counter", + "statistics", + "platform-services", + "documents", + ], + }); + }); + + afterEach(async () => { + await platform.tearDown(); + }); + + afterAll(async () => { + await platform.app.close(); + }); + + it("did create a tab configuration on Drive side", async done => { + await TestDbService.getInstance(platform, true); + + const item = { + name: "new tab test file", + parent_id: "root", + company_id: platform.workspace.company_id, + }; + + const version = {}; + + const response = await e2e_createDocument(platform, item, version); + const doc = deserialize(DriveFileMockClass, response.body); + + const tab = { + company_id: platform.workspace.company_id, + tab_id: "1234567890", + channel_id: "abcdefghij", + item_id: doc.id, + level: "write", + }; + + const token = await platform.auth.getJWTToken(); + + const createdTab = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${platform.workspace.company_id}/tabs/${tab.tab_id}`, + headers: { + authorization: `Bearer ${token}`, + }, + payload: tab, + }); + + expect(createdTab.statusCode).toBe(200); + expect(createdTab.body).toBeDefined(); + expect(createdTab.json().company_id).toBe(tab.company_id); + expect(createdTab.json().tab_id).toBe(tab.tab_id); + expect(createdTab.json().item_id).toBe(tab.item_id); + + const getTabResponse = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${platform.workspace.company_id}/tabs/${tab.tab_id}`, + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(getTabResponse.statusCode).toBe(200); + expect(getTabResponse.body).toBeDefined(); + expect(getTabResponse.json().company_id).toBe(tab.company_id); + expect(getTabResponse.json().tab_id).toBe(tab.tab_id); + expect(getTabResponse.json().item_id).toBe(tab.item_id); + + const documentResponse = await e2e_getDocument(platform, doc.id); + const documentResult = deserialize( + DriveItemDetailsMockClass, + documentResponse.body, + ); + + console.log(documentResult?.item); + + expect( + documentResult?.item?.access_info?.entities?.find( + a => a?.type === "channel" && a.id === "abcdefghij" && a.level === "write", + ), + ).toBeDefined(); + + done?.(); + }); + + it("did refuse to create a tab configuration for an item I can't manage", async done => { + const dbService = await TestDbService.getInstance(platform, true); + const ws0pk = { + id: platform.workspace.workspace_id, + company_id: platform.workspace.company_id, + }; + const otherUser = await dbService.createUser([ws0pk]); + + const item = { + name: "new tab test file", + parent_id: "root", + company_id: platform.workspace.company_id, + access_info: { + entities: [ + { + type: "folder", + id: "parent", + level: "none", + } as any, + ], + }, + }; + + const version = {}; + + const response = await e2e_createDocument(platform, item, version); + const doc = deserialize(DriveFileMockClass, response.body); + + const tab = { + company_id: platform.workspace.company_id, + tab_id: "1234567890", + channel_id: "abcdefghij", + item_id: doc.id, + level: "read", + }; + + const token = await platform.auth.getJWTToken({ sub: otherUser.id }); + + const createdTab = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${platform.workspace.company_id}/tabs/${tab.tab_id}`, + headers: { + authorization: `Bearer ${token}`, + }, + payload: tab, + }); + + expect(createdTab.statusCode).toBe(403); + + done?.(); + }); +}); diff --git a/twake/backend/node/test/e2e/documents/documents.spec.ts b/twake/backend/node/test/e2e/documents/documents.spec.ts new file mode 100644 index 00000000..d964740e --- /dev/null +++ b/twake/backend/node/test/e2e/documents/documents.spec.ts @@ -0,0 +1,238 @@ +import { describe, beforeEach, afterEach, it, expect, afterAll } from "@jest/globals"; +import { deserialize } from "class-transformer"; +import { File } from "../../../src/services/files/entities/file"; +import { ResourceUpdateResponse } from "../../../src/utils/types"; +import { init, TestPlatform } from "../setup"; +import { TestDbService } from "../utils.prepare.db"; +import { + e2e_createDocument, + e2e_createDocumentFile, + e2e_createVersion, + e2e_deleteDocument, + e2e_getDocument, + e2e_searchDocument, + e2e_updateDocument, +} from "./utils"; + +describe("the Drive feature", () => { + let platform: TestPlatform; + + class DriveFileMockClass { + id: string; + name: string; + size: number; + added: string; + parent_id: string; + } + + class DriveItemDetailsMockClass { + path: string[]; + item: DriveFileMockClass; + children: DriveFileMockClass[]; + versions: Record[]; + } + + class SearchResultMockClass { + entities: DriveFileMockClass[]; + } + + beforeEach(async () => { + platform = await init({ + services: [ + "webserver", + "database", + "applications", + "search", + "storage", + "message-queue", + "user", + "search", + "files", + "websocket", + "messages", + "auth", + "realtime", + "channels", + "counter", + "statistics", + "platform-services", + "documents", + ], + }); + }); + + afterEach(async () => { + await platform.tearDown(); + }); + + afterAll(async () => { + await platform.app.close(); + }); + + const createItem = async (): Promise => { + await TestDbService.getInstance(platform, true); + + const item = { + name: "new test file", + parent_id: "root", + company_id: platform.workspace.company_id, + }; + + const version = {}; + + const response = await e2e_createDocument(platform, item, version); + return deserialize(DriveFileMockClass, response.body); + }; + + it("did create the drive item", async done => { + const result = await createItem(); + + expect(result).toBeDefined(); + expect(result.name).toEqual("new test file"); + expect(result.added).toBeDefined(); + + done?.(); + }); + + it("did fetch the drive item", async done => { + await TestDbService.getInstance(platform, true); + + const response = await e2e_getDocument(platform, ""); + const result = deserialize(DriveItemDetailsMockClass, response.body); + + expect(result.item.name).toEqual("root"); + + done?.(); + }); + + it("did fetch the trash", async done => { + await TestDbService.getInstance(platform, true); + + const response = await e2e_getDocument(platform, "trash"); + const result = deserialize(DriveItemDetailsMockClass, response.body); + + expect(result.item.name).toEqual("trash"); + + done?.(); + }); + + it("did delete an item", async done => { + const createItemResult = await createItem(); + + expect(createItemResult.id).toBeDefined(); + + const deleteResponse = await e2e_deleteDocument(platform, createItemResult.id); + expect(deleteResponse.statusCode).toEqual(200); + + done?.(); + }); + + it("did update an item", async done => { + const createItemResult = await createItem(); + + expect(createItemResult.id).toBeDefined(); + + const update = { + name: "somethingelse", + }; + + const updateItemResponse = await e2e_updateDocument(platform, createItemResult.id, update); + const updateItemResult = deserialize( + DriveFileMockClass, + updateItemResponse.body, + ); + + expect(createItemResult.id).toEqual(updateItemResult.id); + expect(updateItemResult.name).toEqual("somethingelse"); + + done?.(); + }); + + it("did move an item to trash", async done => { + const createItemResult = await createItem(); + + expect(createItemResult.id).toBeDefined(); + + const moveToTrashResponse = await e2e_deleteDocument(platform, createItemResult.id); + expect(moveToTrashResponse.statusCode).toEqual(200); + + const listTrashResponse = await e2e_getDocument(platform, "trash"); + const listTrashResult = deserialize( + DriveItemDetailsMockClass, + listTrashResponse.body, + ); + + expect(listTrashResult.item.name).toEqual("trash"); + expect(listTrashResult.children.some(({ id }) => id === createItemResult.id)).toBeTruthy(); + + done?.(); + }); + + // TODO: wait for elastic search index + it("did search for an item", async done => { + const createItemResult = await createItem(); + + expect(createItemResult.id).toBeDefined(); + + await e2e_getDocument(platform, "root"); + await e2e_getDocument(platform, createItemResult.id); + + await new Promise(resolve => setTimeout(resolve, 3000)); + + const searchPayload = { + search: "test", + }; + + const searchResponse = await e2e_searchDocument(platform, searchPayload); + const searchResult = deserialize( + SearchResultMockClass, + searchResponse.body, + ); + + expect(searchResult.entities.length).toBeGreaterThanOrEqual(1); + + done?.(); + }); + + it("did search for an item that doesn't exist", async done => { + await createItem(); + + const unexistingSeachPayload = { + search: "somethingthatdoesn'tandshouldn'texist", + }; + const failSearchResponse = await e2e_searchDocument(platform, unexistingSeachPayload); + const failSearchResult = deserialize( + SearchResultMockClass, + failSearchResponse.body, + ); + + expect(failSearchResult.entities).toHaveLength(0); + + done?.(); + }); + + it("did create a version for a drive item", async done => { + const item = await createItem(); + const fileUploadResponse = await e2e_createDocumentFile(platform); + const fileUploadResult = deserialize>( + ResourceUpdateResponse, + fileUploadResponse.body, + ); + + const file_metadata = { external_id: fileUploadResult.resource.id }; + + await e2e_createVersion(platform, item.id, { filename: "file2", file_metadata }); + await e2e_createVersion(platform, item.id, { filename: "file3", file_metadata }); + await e2e_createVersion(platform, item.id, { filename: "file4", file_metadata }); + + const fetchItemResponse = await e2e_getDocument(platform, item.id); + const fetchItemResult = deserialize( + DriveItemDetailsMockClass, + fetchItemResponse.body, + ); + + expect(fetchItemResult.versions).toHaveLength(4); + + done?.(); + }); +}); diff --git a/twake/backend/node/test/e2e/documents/utils.ts b/twake/backend/node/test/e2e/documents/utils.ts new file mode 100644 index 00000000..1104441c --- /dev/null +++ b/twake/backend/node/test/e2e/documents/utils.ts @@ -0,0 +1,116 @@ +import { DriveFile } from "../../../src/services/documents/entities/drive-file"; +import { FileVersion } from "../../../src/services/documents/entities/file-version"; +import { TestPlatform } from "../setup"; +import formAutoContent from "form-auto-content"; +import fs from "fs"; + +const url = "/internal/services/documents/v1"; + +export const e2e_createDocument = async ( + platform: TestPlatform, + item: Partial, + version: Partial, +) => { + const token = await platform.auth.getJWTToken(); + + return await platform.app.inject({ + method: "POST", + url: `${url}/companies/${platform.workspace.company_id}/item`, + headers: { + authorization: `Bearer ${token}`, + }, + payload: { + item, + version, + }, + }); +}; + +export const e2e_getDocument = async (platform: TestPlatform, id: string | "root" | "trash") => { + const token = await platform.auth.getJWTToken(); + + return await platform.app.inject({ + method: "GET", + url: `${url}/companies/${platform.workspace.company_id}/item/${id}`, + headers: { + authorization: `Bearer ${token}`, + }, + }); +}; + +export const e2e_deleteDocument = async (platform: TestPlatform, id: string | "root" | "trash") => { + const token = await platform.auth.getJWTToken(); + + return await platform.app.inject({ + method: "DELETE", + url: `${url}/companies/${platform.workspace.company_id}/item/${id}`, + headers: { + authorization: `Bearer ${token}`, + }, + }); +}; + +export const e2e_updateDocument = async ( + platform: TestPlatform, + id: string | "root" | "trash", + item: Partial, +) => { + const token = await platform.auth.getJWTToken(); + + return await platform.app.inject({ + method: "POST", + url: `${url}/companies/${platform.workspace.company_id}/item/${id}`, + headers: { + authorization: `Bearer ${token}`, + }, + payload: item, + }); +}; + +export const e2e_searchDocument = async ( + platform: TestPlatform, + payload: Record, +) => { + const token = await platform.auth.getJWTToken(); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${platform.workspace.company_id}/search`, + headers: { + authorization: `Bearer ${token}`, + }, + payload, + }); + + return response; +}; + +export const e2e_createVersion = async ( + platform: TestPlatform, + id: string, + payload: Partial, +) => { + const token = await platform.auth.getJWTToken(); + + return await platform.app.inject({ + method: "POST", + url: `${url}/companies/${platform.workspace.company_id}/item/${id}/version`, + headers: { + authorization: `Bearer ${token}`, + }, + payload, + }); +}; + +export const e2e_createDocumentFile = async (platform: TestPlatform) => { + const filePath = `${__dirname}/assets/test.txt`; + const token = await platform.auth.getJWTToken(); + const form = formAutoContent({ file: fs.createReadStream(filePath) }); + form.headers["authorization"] = `Bearer ${token}`; + + return await platform.app.inject({ + method: "POST", + url: `/internal/services/files/v1/companies/${platform.workspace.company_id}/files`, + ...form, + }); +}; diff --git a/twake/backend/node/test/e2e/workspaces/workspace-users.spec.ts b/twake/backend/node/test/e2e/workspaces/workspace-users.spec.ts new file mode 100644 index 00000000..c70f2e83 --- /dev/null +++ b/twake/backend/node/test/e2e/workspaces/workspace-users.spec.ts @@ -0,0 +1,469 @@ +import { afterAll, beforeAll, describe, expect, it } from "@jest/globals"; +import { init, TestPlatform } from "../setup"; +import { TestDbService, uuid } from "../utils.prepare.db"; +import { v1 as uuidv1 } from "uuid"; + +describe("The /workspace users API", () => { + const url = "/internal/services/workspaces/v1"; + let platform: TestPlatform; + + let testDbService: TestDbService; + + const nonExistentId = uuidv1(); + let companyId = ""; + + const checkUserObject = (resource: any) => { + expect(resource).toMatchObject({ + id: expect.any(String), + company_id: expect.any(String), + workspace_id: expect.any(String), + user_id: expect.any(String), + created_at: expect.any(Number), + role: expect.stringMatching(/moderator|member/), + user: { + id: expect.any(String), + provider: expect.any(String), + provider_id: expect.any(String), + email: expect.any(String), + is_verified: expect.any(Boolean), + picture: expect.any(String), + first_name: expect.any(String), + last_name: expect.any(String), + created_at: expect.any(Number), + deleted: expect.any(Boolean), + status: expect.any(String), + last_activity: expect.any(Number), + }, + }); + }; + + beforeAll(async ends => { + platform = await init({ + services: [ + "database", + "message-queue", + "search", + "webserver", + "user", + "workspaces", + "applications", + "auth", + "console", + "counter", + "storage", + "statistics", + "platform-services", + ], + }); + + companyId = platform.workspace.company_id; + + await platform.database.getConnector().init(); + testDbService = new TestDbService(platform); + await testDbService.createCompany(companyId); + const ws0pk = { id: uuidv1(), company_id: companyId }; + const ws1pk = { id: uuidv1(), company_id: companyId }; + const ws2pk = { id: uuidv1(), company_id: companyId }; + const ws3pk = { id: uuidv1(), company_id: companyId }; + await testDbService.createWorkspace(ws0pk); + await testDbService.createWorkspace(ws1pk); + await testDbService.createWorkspace(ws2pk); + await testDbService.createWorkspace(ws3pk); + await testDbService.createUser([ws0pk, ws1pk]); + await testDbService.createUser([ws2pk], { companyRole: "admin" }); + await testDbService.createUser([ws2pk], { workspaceRole: "moderator" }); + await testDbService.createUser([ws2pk], { workspaceRole: "member" }); + await testDbService.createUser([ws2pk], { workspaceRole: "member" }); + await testDbService.createUser([], { companyRole: "member" }); + await testDbService.createUser([ws3pk], { companyRole: "guest", workspaceRole: "member" }); + ends(); + }); + + afterAll(async ends => { + await platform.tearDown(); + ends(); + }); + + describe("The GET /workspaces/users route", () => { + it("should 401 when not authenticated", async done => { + const companyId = testDbService.company.id; + + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}/users`, + }); + expect(response.statusCode).toBe(401); + done(); + }); + + it("should 404 when workspace not found", async done => { + const userId = testDbService.users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}/users`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + expect(response.statusCode).toBe(404); + done(); + }); + + it("should 200 when ok", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + expect(response.statusCode).toBe(200); + + const resources = response.json()["resources"]; + + expect(resources.length).toBeGreaterThan(0); + + for (const resource of resources) { + checkUserObject(resource); + } + + done(); + }); + }); + + describe("The GET /workspaces/users/:user_id route", () => { + it("should 401 when not authenticated", async done => { + const companyId = testDbService.company.id; + const userId = testDbService.users[0].id; + + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}/users/${userId}`, + }); + expect(response.statusCode).toBe(401); + done(); + }); + + it("should 404 when workspace not found", async done => { + const userId = testDbService.users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}/users/${userId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + expect(response.statusCode).toBe(404); + done(); + }); + + it("should 200 when ok", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[0].workspace.id; + const userId = testDbService.workspaces[0].users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users/${userId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + expect(response.statusCode).toBe(200); + + const resource = response.json()["resource"]; + checkUserObject(resource); + done(); + }); + }); + + describe("The POST /workspaces/users route (add)", () => { + it("should 401 when not authenticated", async done => { + const companyId = testDbService.company.id; + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}/users`, + }); + expect(response.statusCode).toBe(401); + done(); + }); + + it("should 403 user is not workspace moderator", async done => { + const userId = testDbService.users[0].id; + const anotherUserId = testDbService.users[1].id; + const workspaceId = testDbService.workspaces[0].workspace.id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + user_id: anotherUserId, + role: "moderator", + }, + }, + }); + expect(response.statusCode).toBe(403); + done(); + }); + + it("should 400 when requested user not in company", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[1].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + user_id: nonExistentId, + role: "moderator", + }, + }, + }); + expect(response.statusCode).toBe(400); + done(); + }); + + it("should 201 when requested already in workspace (ignore)", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[1].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + user_id: userId, + role: "moderator", + }, + }, + }); + expect(response.statusCode).toBe(201); + done(); + }); + + it("should 200 when ok", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[1].id; + const anotherUserId = testDbService.workspaces[0].users[0].id; + + let workspaceUsersCount = await testDbService.getWorkspaceUsersCountFromDb(workspaceId); + let companyUsersCount = await testDbService.getCompanyUsersCountFromDb(companyId); + + console.log(testDbService.workspaces[2].users); + console.log(workspaceUsersCount); + + expect(workspaceUsersCount).toBe(4); + // expect(companyUsersCount).toBe(6); + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + user_id: anotherUserId, + role: "moderator", + }, + }, + }); + + expect(response.statusCode).toBe(201); + const resource = response.json()["resource"]; + checkUserObject(resource); + + workspaceUsersCount = await testDbService.getWorkspaceUsersCountFromDb(workspaceId); + companyUsersCount = await testDbService.getCompanyUsersCountFromDb(companyId); + expect(workspaceUsersCount).toBe(5); + // expect(companyUsersCount).toBe(6); + + done(); + }); + }); + + describe("The POST /workspaces/users/:user_id route (update)", () => { + it("should 401 when not authenticated", async done => { + const companyId = testDbService.company.id; + const userId = testDbService.users[0].id; + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}/users/${userId}`, + }); + expect(response.statusCode).toBe(401); + done(); + }); + + it("should 403 user is not workspace moderator", async done => { + const userId = testDbService.users[0].id; + const anotherUserId = testDbService.users[1].id; + const workspaceId = testDbService.workspaces[0].workspace.id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users/${userId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + user_id: anotherUserId, + role: "moderator", + }, + }, + }); + expect(response.statusCode).toBe(403); + done(); + }); + + it("should 404 when user not found in workspace", async done => { + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[1].id; + const anotherWorkspaceUserId = testDbService.workspaces[3].users[0].id; + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users/${anotherWorkspaceUserId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + role: "moderator", + }, + }, + }); + + expect(response.statusCode).toBe(404); + done(); + }); + + it("should 200 when ok", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[1].id; + const anotherUserId = testDbService.workspaces[2].users[2].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users/${anotherUserId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + role: "moderator", + }, + }, + }); + + expect(response.statusCode).toBe(201); + const resource = response.json()["resource"]; + checkUserObject(resource); + + expect(resource["role"]).toBe("moderator"); + + const usersCount = await testDbService.getWorkspaceUsersCountFromDb(workspaceId); + expect(usersCount).toBe(5); + + done(); + }); + }); + + describe("The DELETE /workspaces/users/:user_id route", () => { + it("should 401 when not authenticated", async done => { + const companyId = testDbService.company.id; + const anotherUserId = testDbService.users[1].id; + + const response = await platform.app.inject({ + method: "DELETE", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}/users/${anotherUserId}`, + }); + expect(response.statusCode).toBe(401); + done(); + }); + + it("should 403 user is not workspace moderator", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[3].id; + const anotherUserId = testDbService.workspaces[2].users[1].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "DELETE", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users/${anotherUserId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + + console.log(response.body); + + expect(response.statusCode).toBe(403); + done(); + }); + + it("should 404 when user not found in workspace", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[1].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + const response = await platform.app.inject({ + method: "DELETE", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users/${nonExistentId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + + expect(response.statusCode).toBe(404); + + done(); + }); + + it("should 200 when ok", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[1].id; + const anotherUserId = testDbService.workspaces[2].users[2].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + let response = await platform.app.inject({ + method: "DELETE", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users/${anotherUserId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + + expect(response.statusCode).toBe(204); + + response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}/users`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + + expect(response.statusCode).toBe(200); + const resources = response.json()["resources"]; + expect(resources.find((a: { user_id: uuid }) => a.user_id === anotherUserId)).toBeUndefined(); + + const usersCount = await testDbService.getWorkspaceUsersCountFromDb(workspaceId); + expect(usersCount).toBe(4); + + done(); + }); + }); +}); diff --git a/twake/backend/node/test/e2e/workspaces/workspaces.spec.ts b/twake/backend/node/test/e2e/workspaces/workspaces.spec.ts new file mode 100644 index 00000000..d251e304 --- /dev/null +++ b/twake/backend/node/test/e2e/workspaces/workspaces.spec.ts @@ -0,0 +1,513 @@ +import { afterAll, beforeAll, describe, expect, it } from "@jest/globals"; +import { init, TestPlatform } from "../setup"; +import { TestDbService } from "../utils.prepare.db"; +import { v1 as uuidv1 } from "uuid"; + +describe("The /workspaces API", () => { + const url = "/internal/services/workspaces/v1"; + let platform: TestPlatform; + + let testDbService: TestDbService; + + const nonExistentId = uuidv1(); + let companyId = ""; + + beforeAll(async ends => { + platform = await init({ + services: [ + "database", + "message-queue", + "webserver", + "user", + "search", + "workspaces", + "auth", + "console", + "counter", + "storage", + "applications", + "statistics", + "platform-services", + ], + }); + + companyId = platform.workspace.company_id; + + await platform.database.getConnector().init(); + testDbService = new TestDbService(platform); + await testDbService.createCompany(companyId); + const ws0pk = { id: uuidv1(), company_id: companyId }; + const ws1pk = { id: uuidv1(), company_id: companyId }; + const ws2pk = { id: uuidv1(), company_id: companyId }; + await testDbService.createWorkspace(ws0pk); + await testDbService.createWorkspace(ws1pk); + await testDbService.createWorkspace(ws2pk); + await testDbService.createUser([ws0pk, ws1pk]); + await testDbService.createUser([ws2pk], { companyRole: "admin" }); + await testDbService.createUser([ws2pk], { companyRole: undefined, workspaceRole: "moderator" }); + await testDbService.createUser([], { companyRole: "guest" }); + ends(); + }); + + afterAll(async ends => { + await platform.tearDown(); + ends(); + }); + + describe("The GET /workspaces/ route", () => { + it("should 401 when not authenticated", async done => { + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${nonExistentId}/workspaces`, + }); + expect(response.statusCode).toBe(401); + done(); + }); + + it("should 404 when company not found", async done => { + const userId = testDbService.workspaces[0].users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${nonExistentId}/workspaces`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + expect(response.statusCode).toBe(404); + done(); + }); + + it("should 200 when company belongs to user", async done => { + const userId = testDbService.workspaces[0].users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + const companyId = testDbService.company.id; + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + expect(response.statusCode).toBe(200); + + const resources = response.json()["resources"]; + + expect(resources.length).toBe(2); + + for (const resource of resources) { + expect(resource).toMatchObject({ + id: expect.any(String), + company_id: expect.any(String), + name: expect.any(String), + logo: expect.any(String), + default: expect.any(Boolean), + archived: expect.any(Boolean), + role: expect.stringMatching(/moderator|member/), + }); + + if (resource.stats) { + expect(resource.stats).toMatchObject({ + created_at: expect.any(Number), + total_members: 1, + }); + } + } + + done(); + }); + }); + + describe("The GET /workspaces/:workspace_id route", () => { + it("should 401 when not authenticated", async done => { + const workspaceId = testDbService.workspaces[0].workspace.id; + + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${nonExistentId}/workspaces/${workspaceId}`, + }); + expect(response.statusCode).toBe(401); + done(); + }); + + it("should 404 when workspace not found", async done => { + const userId = testDbService.workspaces[0].users[0].id; + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${uuidv1()}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + expect(response.statusCode).toBe(404); + done(); + }); + + it("should 403 when user not belong to workspace and not company_admin", async done => { + const workspaceId = testDbService.workspaces[2].workspace.id; + const userIdFromAnotherWorkspace = testDbService.workspaces[0].users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userIdFromAnotherWorkspace }); + + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + expect(response.statusCode).toBe(403); + + expect(response.json()).toEqual({ + error: "Forbidden", + message: `You are not belong to workspace ${workspaceId}`, + statusCode: 403, + }); + + done(); + }); + + it("should 200 when user is company_admin", async done => { + const workspaceId = testDbService.workspaces[0].workspace.id; + const userIdFromAnotherWorkspace = testDbService.workspaces[2].users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userIdFromAnotherWorkspace }); + + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + expect(response.statusCode).toBe(200); + + const resource = response.json()["resource"]; + + expect(resource).toMatchObject({ + id: expect.any(String), + company_id: expect.any(String), + name: expect.any(String), + logo: expect.any(String), + default: expect.any(Boolean), + archived: expect.any(Boolean), + }); + + if (resource.stats) { + expect(resource.stats).toMatchObject({ + created_at: expect.any(Number), + total_members: 1, + }); + } + + done(); + }); + + it("should 200 when user is belong to workspace", async done => { + const workspaceId = testDbService.workspaces[0].workspace.id; + const userIdFromThisWorkspace = testDbService.workspaces[0].users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userIdFromThisWorkspace }); + + const response = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + expect(response.statusCode).toBe(200); + + const resource = response.json()["resource"]; + + expect(resource).toMatchObject({ + id: expect.any(String), + company_id: expect.any(String), + name: expect.any(String), + logo: expect.any(String), + default: expect.any(Boolean), + archived: expect.any(Boolean), + role: expect.stringMatching(/moderator|member/), + }); + + if (resource.stats) { + expect(resource.stats).toMatchObject({ + created_at: expect.any(Number), + total_members: 1, + }); + } + + done(); + }); + }); + + // create + + describe("The POST /workspaces route (creating workspace)", () => { + it("should 401 when not authenticated", async done => { + const companyId = testDbService.company.id; + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}`, + }); + expect(response.statusCode).toBe(401); + done(); + }); + + it("should 403 when user is not (company member or company admin) ", async done => { + const companyId = testDbService.company.id; + const userId = testDbService.users[3].id; + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + name: "Some channel name", + logo: "", + default: false, + }, + }, + }); + + expect(response.statusCode).toBe(403); + done(); + }); + + it("should 201 when workspace created well", async done => { + const companyId = testDbService.company.id; + const userId = testDbService.users[0].id; + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + name: "Random channel name", + logo: "logo", + default: false, + }, + }, + }); + + expect(response.statusCode).toBe(201); + + const resource = response.json()["resource"]; + + expect(resource).toMatchObject({ + id: expect.any(String), + company_id: expect.any(String), + name: expect.any(String), + logo: expect.any(String), + default: expect.any(Boolean), + archived: expect.any(Boolean), + role: expect.stringMatching(/moderator/), + }); + + done(); + }); + }); + + // update + + describe("The POST /workspaces/:workspace_id route (updating workspace)", () => { + it("should 401 when not authenticated", async done => { + const companyId = testDbService.company.id; + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}`, + payload: { resource: {} }, + }); + expect(response.statusCode).toBe(401); + done(); + }); + + it("should 403 when not workspace not found", async done => { + const companyId = testDbService.company.id; + const userId = testDbService.workspaces[0].users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { resource: {} }, + }); + + expect(response.statusCode).toBe(403); + + done(); + }); + + it("should 403 when not belong to workspace", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[1].workspace.id; + const userId = testDbService.workspaces[0].users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { resource: {} }, + }); + + expect(response.statusCode).toBe(403); + + done(); + }); + + it("should 403 when not workspace moderator", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[1].workspace.id; + const userId = testDbService.workspaces[0].users[0].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { resource: {} }, + }); + + expect(response.statusCode).toBe(403); + + done(); + }); + + it("should 200 when admin of company (full update)", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[0].id; // company owner + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + name: "Another workspace name", + logo: "logo", + default: false, + archived: false, + }, + }, + }); + + expect(response.statusCode).toBe(200); + + const resource = response.json()["resource"]; + + expect(resource).toMatchObject({ + id: workspaceId, + company_id: companyId, + name: "Another workspace name", + logo: expect.any(String), + default: false, + archived: false, + role: "moderator", //Company admin is a moderator + }); + + done(); + }); + + it("should 200 when moderator of workspace (partial update)", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[1].id; // workspace admin + + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + payload: { + resource: { + name: "My awesome workspace", + default: true, + logo: "", + }, + }, + }); + + expect(response.statusCode).toBe(200); + + const resource = response.json()["resource"]; + + expect(resource).toMatchObject({ + id: workspaceId, + company_id: companyId, + name: "My awesome workspace", + logo: expect.any(String), + default: true, + archived: false, + role: "moderator", + }); + + done(); + }); + }); + + // delete + + describe("The DELETE /workspaces route", () => { + it("should 401 when not authenticated", async done => { + const companyId = testDbService.company.id; + + const response = await platform.app.inject({ + method: "DELETE", + url: `${url}/companies/${companyId}/workspaces/${nonExistentId}`, + }); + expect(response.statusCode).toBe(401); + done(); + }); + + it("should 403 when user is not (company member or company admin) ", async done => { + const companyId = testDbService.company.id; + const userId = testDbService.users[3].id; + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + const workspaceId = testDbService.workspaces[0].workspace.id; + + const response = await platform.app.inject({ + method: "DELETE", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + + expect(response.statusCode).toBe(403); + done(); + }); + + it("should 204 when workspace deleted", async done => { + const companyId = testDbService.company.id; + const workspaceId = testDbService.workspaces[2].workspace.id; + const userId = testDbService.workspaces[2].users[0].id; + const jwtToken = await platform.auth.getJWTToken({ sub: userId }); + + const response = await platform.app.inject({ + method: "DELETE", + url: `${url}/companies/${companyId}/workspaces/${workspaceId}`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + + expect(response.statusCode).toBe(204); + + const checkResponse = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${companyId}/workspaces`, + headers: { authorization: `Bearer ${jwtToken}` }, + }); + + const checkResponseJson = checkResponse.json(); + + expect(checkResponseJson.resources.find((a: any) => a.id === workspaceId)).toBe(undefined); + + done(); + }); + }); +});