diff --git a/core/src/common/StoragesInconsistencyError.ts b/core/src/common/StoragesInconsistencyError.ts new file mode 100644 index 00000000..cd065906 --- /dev/null +++ b/core/src/common/StoragesInconsistencyError.ts @@ -0,0 +1 @@ +export default class StoragesInconsistencyError extends Error {} diff --git a/core/src/common/UnexpectedError.ts b/core/src/common/UnexpectedError.ts new file mode 100644 index 00000000..f42e7bcb --- /dev/null +++ b/core/src/common/UnexpectedError.ts @@ -0,0 +1,5 @@ +export default class UnexpectedError extends Error { + constructor() { + super("An unexpected error occurred while performing the operation"); + } +} diff --git a/core/src/common/Usecase.ts b/core/src/common/Usecase.ts index 877273a7..9e6ca59b 100644 --- a/core/src/common/Usecase.ts +++ b/core/src/common/Usecase.ts @@ -1,60 +1,111 @@ import IArchiver from "../dependencies/IArchiver"; import IAuthenticationStrategy from "../dependencies/IAuthenticationStrategy"; +import IExternalCacheService from "../dependencies/IExternalCacheService"; +import ILogger from "../dependencies/ILogger"; import IRequestContext from "../dependencies/IRequestContext"; import IStorages from "../dependencies/IStorages"; import IUsecaseConfig from "../dependencies/IUsecaseConfig"; +import { IUserWithRoles } from "../entities/User"; import Authenticator from "../services/Authenticator"; import Authorizer from "../services/Authorizer"; import OperationLogger from "../services/OperationLogger"; +import { FunctionalError } from "./functionalErrors"; +import UnexpectedError from "./UnexpectedError"; -export default abstract class Usecase { +export default abstract class Usecase { // Services protected authorizer: Authorizer; + protected authenticator: Authenticator; protected operationLogger: OperationLogger; // Dependencies protected archiver: IArchiver; - protected storages: IStorages; + private authenticationStrategies: IAuthenticationStrategy[]; private config: IUsecaseConfig; + protected externalCacheServices: IExternalCacheService[]; + protected log: ILogger; private requestContext: IRequestContext; - private authenticationStrategies: IAuthenticationStrategy[]; + protected storages: IStorages; + // State + protected user: IUserWithRoles | null; constructor(options: { archiver: IArchiver; authenticationStrategies: IAuthenticationStrategy[]; + externalCacheServices: IExternalCacheService[]; config: IUsecaseConfig; requestContext: IRequestContext; storages: IStorages; + logger: ILogger; }) { // Dependencies - this.authenticationStrategies = options.authenticationStrategies; this.archiver = options.archiver; + this.authenticationStrategies = options.authenticationStrategies; this.config = options.config; + this.externalCacheServices = options.externalCacheServices; + this.log = options.logger; this.requestContext = options.requestContext; this.storages = options.storages; // Services - const authenticator = new Authenticator( - options.authenticationStrategies, - this.requestContext.authToken - ); - this.authorizer = new Authorizer( + this.authenticator = new Authenticator( this.storages.users, - authenticator, - this.config.enforceAuth - ); - this.operationLogger = new OperationLogger( - this.storages.operationLogs, - this.authorizer + this.authenticationStrategies, + this.config.enforceAuth, + this.requestContext.authToken ); + this.authorizer = new Authorizer(this.config.enforceAuth); + this.operationLogger = new OperationLogger(this.storages.operationLogs); + + // State + this.user = null; + } + + async exec(...args: Arguments): Promise { + const startedAt = Date.now(); + this.log.addToContext("usecase", this.constructor.name); + this.log.info("usecase execution started"); + + try { + this.user = await this.authenticator.getUser(); + this.log.addToContext("userId", this.user?.id ?? "anonymous"); + this.authorizer._setUser(this.user); + this.operationLogger._setUser(this.user); + + const result = await this._exec(...args); + + this.log.info("usecase execution terminated successfully", { + execTimeMs: Date.now() - startedAt + }); + + return result; + } catch (error) { + if (error instanceof FunctionalError) { + this.log.info("usecase execution terminated with error", { + execTimeMs: Date.now() - startedAt, + error: error + }); + + throw error; + } else { + this.log.error("usecase execution failed unexpectedly", { + execTimeMs: Date.now() - startedAt, + error: error + }); + + throw new UnexpectedError(); + } + } } - abstract exec(...args: any[]): any; + protected abstract _exec(...args: Arguments): Promise; - protected makeUsecase(UsecaseClass: { + protected makeUsecase>(UsecaseClass: { new (dependencies: { archiver: IArchiver; authenticationStrategies: IAuthenticationStrategy[]; config: IUsecaseConfig; + externalCacheServices: IExternalCacheService[]; + logger: ILogger; requestContext: IRequestContext; storages: IStorages; }): U; @@ -63,6 +114,8 @@ export default abstract class Usecase { archiver: this.archiver, authenticationStrategies: this.authenticationStrategies, config: this.config, + externalCacheServices: this.externalCacheServices, + logger: this.log, requestContext: this.requestContext, storages: this.storages }); diff --git a/core/src/common/errors.ts b/core/src/common/functionalErrors.ts similarity index 60% rename from core/src/common/errors.ts rename to core/src/common/functionalErrors.ts index f78c82ab..82d772c4 100644 --- a/core/src/common/errors.ts +++ b/core/src/common/functionalErrors.ts @@ -1,28 +1,21 @@ import { IIdpUser } from "../entities/User"; +export class FunctionalError extends Error {} + // Auth errors -export class AuthenticationStrategySetupError extends Error { - constructor( - public authenticationStrategy: string, - message: string, - public originalError: any - ) { - super(message); - } -} -export class AuthenticationRequiredError extends Error { +export class AuthenticationRequiredError extends FunctionalError { constructor() { super("This operation requires the request to be authenticated"); } } -export class NoUserCorrespondingToIdpUserError extends Error { +export class NoUserCorrespondingToIdpUserError extends FunctionalError { constructor(idpUser: IIdpUser) { super( `Access denied. To gain access, ask an admin to create a user with idp = ${idpUser.idp} and idpId = ${idpUser.id}` ); } } -export class MissingRoleError extends Error { +export class MissingRoleError extends FunctionalError { constructor() { super( "The user doesn't have the necessary roles to perform this operation" @@ -31,29 +24,29 @@ export class MissingRoleError extends Error { } // Configuration errors -export class ConfigurationNotValidError extends Error { +export class ConfigurationNotValidError extends FunctionalError { constructor(configurationProperty: string) { super(`${configurationProperty} is not a valid configuration object`); } } // App errors -export class AppNameNotValidError extends Error { +export class AppNameNotValidError extends FunctionalError { constructor(name: string) { super(`${name} is not a valid name for an app`); } } -export class AppNotFoundError extends Error { +export class AppNotFoundError extends FunctionalError { constructor(searchValue: string, searchProperty: string) { super(`No app found with ${searchProperty} = ${searchValue}`); } } -export class ConflictingAppError extends Error { +export class ConflictingAppError extends FunctionalError { constructor(name: string) { super(`An app with name = ${name} already exists`); } } -export class AppHasEntrypointsError extends Error { +export class AppHasEntrypointsError extends FunctionalError { constructor(id: string) { super( `Can't delete app with id = ${id} because it has linked entrypoints` @@ -62,31 +55,31 @@ export class AppHasEntrypointsError extends Error { } // Bundle errors -export class BundleNameOrTagNotValidError extends Error { +export class BundleNameOrTagNotValidError extends FunctionalError { constructor(nameOrTag: string, type: "name" | "tag") { super(`${nameOrTag} is not a valid ${type} for a bundle`); } } -export class BundleNameTagCombinationNotValidError extends Error { +export class BundleNameTagCombinationNotValidError extends FunctionalError { constructor(nameTagCombination: string) { super( `${nameTagCombination} is not a valid name:tag combination for a bundle` ); } } -export class BundleFallbackAssetNotFoundError extends Error { +export class BundleFallbackAssetNotFoundError extends FunctionalError { constructor(fallbackAssetPath: string) { super( `Asset ${fallbackAssetPath} not found in bundle, cannot be set as fallback asset` ); } } -export class BundleNotFoundError extends Error { +export class BundleNotFoundError extends FunctionalError { constructor(searchValue: string, searchProperty: string) { super(`No bundle found with ${searchProperty} = ${searchValue}`); } } -export class BundlesInUseError extends Error { +export class BundlesInUseError extends FunctionalError { constructor(ids: string[]) { const bundlesIdsString = ids.join(", "); super( @@ -94,34 +87,29 @@ export class BundlesInUseError extends Error { ); } } -export class ArchiveCreationError extends Error { - constructor() { - super("Error creating archive from files"); - } -} -export class ArchiveExtractionError extends Error { +export class ArchiveExtractionError extends FunctionalError { constructor() { super("Error extracting files from archive"); } } // Entrypoint errors -export class EntrypointUrlMatcherNotValidError extends Error { +export class EntrypointUrlMatcherNotValidError extends FunctionalError { constructor(urlMatcher: string) { super(`${urlMatcher} is not a valid urlMatcher for an entrypoint`); } } -export class EntrypointNotFoundError extends Error { +export class EntrypointNotFoundError extends FunctionalError { constructor(searchValue: string, searchProperty: string) { super(`No entrypoint found with ${searchProperty} = ${searchValue}`); } } -export class ConflictingEntrypointError extends Error { +export class ConflictingEntrypointError extends FunctionalError { constructor(urlMatcher: string) { super(`An entrypoint with urlMatcher = ${urlMatcher} already exists`); } } -export class EntrypointMismatchedAppIdError extends Error { +export class EntrypointMismatchedAppIdError extends FunctionalError { constructor(entrypointUrlMatcher: string, appName: string) { super( `Entrypoint with urlMatcher = ${entrypointUrlMatcher} doesn't link to app with name = ${appName}` @@ -129,13 +117,40 @@ export class EntrypointMismatchedAppIdError extends Error { } } +// External cache errors +export class ExternalCacheTypeNotSupportedError extends FunctionalError { + constructor(type: string) { + super(`${type} is not a supported external cache type`); + } +} +export class ExternalCacheDomainNotValidError extends FunctionalError { + constructor(domain: string) { + super(`${domain} is not a valid domain name`); + } +} +export class ExternalCacheConfigurationNotValidError extends FunctionalError { + constructor() { + super("Invalid external cache configuration object"); + } +} +export class ConflictingExternalCacheError extends FunctionalError { + constructor(domain: string) { + super(`An external cache with domain = ${domain} already exists`); + } +} +export class ExternalCacheNotFoundError extends FunctionalError { + constructor(id: string) { + super(`No external cache found with id = ${id}`); + } +} + // Endpoint response errors -export class NoMatchingEntrypointError extends Error { +export class NoMatchingEntrypointError extends FunctionalError { constructor(public requestedUrl: string) { super(`No entrypoint found matching requestedUrl = ${requestedUrl}`); } } -export class NoBundleOrRedirectToError extends Error { +export class NoBundleOrRedirectToError extends FunctionalError { constructor(public matchingEntrypointUrlMatcher: string) { super( `Entrypoint with urlMatcher = ${matchingEntrypointUrlMatcher} doesn't specify neither a bundle to serve nor a location to redirect to` @@ -144,54 +159,41 @@ export class NoBundleOrRedirectToError extends Error { } // Group and role errors -export class GroupNotFoundError extends Error { +export class GroupNotFoundError extends FunctionalError { constructor(id: string) { super(`No group found with id = ${id}`); } } -export class SomeGroupNotFoundError extends Error { +export class SomeGroupNotFoundError extends FunctionalError { constructor(ids: string[]) { const idsString = ids.join(", "); super(`Not all ids = [ ${idsString} ] correspond to an existing group`); } } -export class ConflictingGroupError extends Error { +export class ConflictingGroupError extends FunctionalError { constructor(name: string) { super(`A group with name = ${name} already exists`); } } -export class GroupHasUsersError extends Error { +export class GroupHasUsersError extends FunctionalError { constructor(id: string) { super(`Can't delete group with id = ${id} because it has linked users`); } } -export class RoleNotValidError extends Error { +export class RoleNotValidError extends FunctionalError { constructor(role: string) { super(`${role} is not a valid role`); } } // User errors -export class UserNotFoundError extends Error { +export class UserNotFoundError extends FunctionalError { constructor(id: string) { super(`No user found with id = ${id}`); } } -export class ConflictingUserError extends Error { +export class ConflictingUserError extends FunctionalError { constructor(idp: string, idpId: string) { super(`A user with idp = ${idp} and idpId = ${idpId} already exists`); } } - -// Storages errors -export class StoragesSetupError extends Error { - constructor(message: string, public originalError: any) { - super(message); - } -} -export class GenericStoragesError extends Error { - constructor(public originalError: Error) { - super("An error occurred while accessing StaticDeploy's storage"); - } -} -export class StoragesInconsistencyError extends Error {} diff --git a/core/src/common/getSupportedExternalCacheTypes.ts b/core/src/common/getSupportedExternalCacheTypes.ts new file mode 100644 index 00000000..1a45b72a --- /dev/null +++ b/core/src/common/getSupportedExternalCacheTypes.ts @@ -0,0 +1,10 @@ +import { map } from "lodash"; + +import IExternalCacheService from "../dependencies/IExternalCacheService"; +import { IExternalCacheType } from "../entities/ExternalCache"; + +export default function getSupportedExternalCacheTypes( + externalCacheServices: IExternalCacheService[] +): IExternalCacheType[] { + return map(externalCacheServices, "externalCacheType"); +} diff --git a/core/src/dependencies/IExternalCacheService.ts b/core/src/dependencies/IExternalCacheService.ts new file mode 100644 index 00000000..c08f172f --- /dev/null +++ b/core/src/dependencies/IExternalCacheService.ts @@ -0,0 +1,9 @@ +import { IExternalCache, IExternalCacheType } from "../entities/ExternalCache"; + +export default interface IExternalCacheService { + externalCacheType: IExternalCacheType; + purge( + paths: string[], + configuration: IExternalCache["configuration"] + ): Promise; +} diff --git a/core/src/dependencies/IExternalCachesStorage.ts b/core/src/dependencies/IExternalCachesStorage.ts new file mode 100644 index 00000000..5479c89a --- /dev/null +++ b/core/src/dependencies/IExternalCachesStorage.ts @@ -0,0 +1,26 @@ +import { IExternalCache } from "../entities/ExternalCache"; + +export default interface IExternalCachesStorage { + findOne(id: string): Promise; + findOneByDomain(domain: string): Promise; + findMany(): Promise; + oneExistsWithDomain(domain: string): Promise; + createOne(externalCache: { + id: string; + type: string; + domain: string; + configuration: IExternalCache["configuration"]; + createdAt: Date; + updatedAt: Date; + }): Promise; + updateOne( + id: string, + patch: { + type?: string; + domain?: string; + configuration?: IExternalCache["configuration"]; + updatedAt: Date; + } + ): Promise; + deleteOne(id: string): Promise; +} diff --git a/core/src/dependencies/ILogger.ts b/core/src/dependencies/ILogger.ts new file mode 100644 index 00000000..c369f908 --- /dev/null +++ b/core/src/dependencies/ILogger.ts @@ -0,0 +1,10 @@ +interface IDetails { + execTimeMs?: number; + error?: any; + [key: string]: any; +} +export default interface ILogger { + addToContext(key: string, value: string): void; + info(message: string, details?: IDetails): void; + error(message: string, details?: IDetails): void; +} diff --git a/core/src/dependencies/IStorages.ts b/core/src/dependencies/IStorages.ts index edb6899c..0fecb4cd 100644 --- a/core/src/dependencies/IStorages.ts +++ b/core/src/dependencies/IStorages.ts @@ -2,6 +2,7 @@ import { IHealthCheckResult } from "../entities/HealthCheckResult"; import IAppsStorage from "./IAppsStorage"; import IBundlesStorage from "./IBundlesStorage"; import IEntrypointsStorage from "./IEntrypointsStorage"; +import IExternalCachesStorage from "./IExternalCachesStorage"; import IGroupsStorage from "./IGroupsStorage"; import IOperationLogsStorage from "./IOperationLogsStorage"; import IUsersStorage from "./IUsersStorage"; @@ -10,6 +11,7 @@ export default interface IStorages { apps: IAppsStorage; bundles: IBundlesStorage; entrypoints: IEntrypointsStorage; + externalCaches: IExternalCachesStorage; groups: IGroupsStorage; operationLogs: IOperationLogsStorage; users: IUsersStorage; diff --git a/core/src/entities/App.ts b/core/src/entities/App.ts index eb24f0d9..daf491cc 100644 --- a/core/src/entities/App.ts +++ b/core/src/entities/App.ts @@ -1,5 +1,5 @@ import basicCharsRegexp from "../common/basicCharsRegexp"; -import { AppNameNotValidError } from "../common/errors"; +import { AppNameNotValidError } from "../common/functionalErrors"; import { IConfiguration } from "./Configuration"; export interface IApp { diff --git a/core/src/entities/Bundle.ts b/core/src/entities/Bundle.ts index a6789698..2697288a 100644 --- a/core/src/entities/Bundle.ts +++ b/core/src/entities/Bundle.ts @@ -2,7 +2,7 @@ import basicCharsRegexp from "../common/basicCharsRegexp"; import { BundleNameOrTagNotValidError, BundleNameTagCombinationNotValidError -} from "../common/errors"; +} from "../common/functionalErrors"; import { IAsset, IAssetWithoutContent } from "./Asset"; export interface IBundle { diff --git a/core/src/entities/Configuration.ts b/core/src/entities/Configuration.ts index c9bb0a46..01fccddd 100644 --- a/core/src/entities/Configuration.ts +++ b/core/src/entities/Configuration.ts @@ -1,8 +1,6 @@ -import every from "lodash/every"; -import isPlainObject from "lodash/isPlainObject"; -import isString from "lodash/isString"; +import { every, isPlainObject, isString } from "lodash"; -import { ConfigurationNotValidError } from "../common/errors"; +import { ConfigurationNotValidError } from "../common/functionalErrors"; export interface IConfiguration { [key: string]: string; diff --git a/core/src/entities/Entrypoint.ts b/core/src/entities/Entrypoint.ts index 359752d4..796b2b0d 100644 --- a/core/src/entities/Entrypoint.ts +++ b/core/src/entities/Entrypoint.ts @@ -1,7 +1,7 @@ import { isAbsolute, normalize } from "path"; import isFQDN from "validator/lib/isFQDN"; -import { EntrypointUrlMatcherNotValidError } from "../common/errors"; +import { EntrypointUrlMatcherNotValidError } from "../common/functionalErrors"; import { IConfiguration } from "./Configuration"; export interface IEntrypoint { diff --git a/core/src/entities/ExternalCache.ts b/core/src/entities/ExternalCache.ts new file mode 100644 index 00000000..9f455c0e --- /dev/null +++ b/core/src/entities/ExternalCache.ts @@ -0,0 +1,98 @@ +import { + every, + find, + isEqual, + isPlainObject, + isString, + keys, + map +} from "lodash"; +import isFQDN from "validator/lib/isFQDN"; + +import { + ExternalCacheConfigurationNotValidError, + ExternalCacheDomainNotValidError, + ExternalCacheTypeNotSupportedError +} from "../common/functionalErrors"; + +export interface IExternalCache { + id: string; + domain: string; + type: string; + configuration: { + [key: string]: string; + }; + createdAt: Date; + updatedAt: Date; +} + +export interface IExternalCacheType { + name: string; + label: string; + configurationFields: { + name: string; + label: string; + placeholder: string; + }[]; +} + +export function getMatchingExternalCacheType( + externalCacheTypes: IExternalCacheType[], + type: string +): IExternalCacheType | null { + return find(externalCacheTypes, { name: type }) || null; +} + +/* + * An external cache type is valid when there is a supported external cache + * type matching it + */ +export function isExternalCacheTypeSupported( + type: string, + supportedExternalCacheTypes: IExternalCacheType[] +): boolean { + return !!getMatchingExternalCacheType(supportedExternalCacheTypes, type); +} +export function validateExternalCacheType( + type: string, + supportedExternalCacheTypes: IExternalCacheType[] +): void { + if (!isExternalCacheTypeSupported(type, supportedExternalCacheTypes)) { + throw new ExternalCacheTypeNotSupportedError(type); + } +} + +export function isExternalCacheDomainValid(domain: string): boolean { + return isFQDN(domain); +} +export function validateExternalCacheDomain(domain: string): void { + if (!isExternalCacheDomainValid(domain)) { + throw new ExternalCacheDomainNotValidError(domain); + } +} + +/* + * A valid external cache configuration object is a (string, string) dictionary + * whose fields match the configurationField-s defined in the externalCacheType + */ +export function isExternalCacheConfigurationValid( + configuration: any, + externalCacheType: IExternalCacheType +): boolean { + return ( + isPlainObject(configuration) && + every(configuration, isString) && + isEqual( + map(externalCacheType.configurationFields, "name"), + keys(configuration) + ) + ); +} +export function validateExternalCacheConfiguration( + configuration: any, + externalCacheType: IExternalCacheType +): void { + if (!isExternalCacheConfigurationValid(configuration, externalCacheType)) { + throw new ExternalCacheConfigurationNotValidError(); + } +} diff --git a/core/src/entities/OperationLog.ts b/core/src/entities/OperationLog.ts index e3bf77eb..8eecf286 100644 --- a/core/src/entities/OperationLog.ts +++ b/core/src/entities/OperationLog.ts @@ -13,6 +13,12 @@ export enum Operation { UpdateEntrypoint = "entrypoints:update", DeleteEntrypoint = "entrypoints:delete", + // External Caches + CreateExternalCache = "externalCaches:create", + UpdateExternalCache = "externalCaches:update", + DeleteExternalCache = "externalCaches:delete", + PurgeExternalCache = "externalCaches:purge", + // Groups CreateGroup = "groups:create", UpdateGroup = "groups:update", diff --git a/core/src/entities/Role/index.ts b/core/src/entities/Role/index.ts index 9cf05fd7..99316be1 100644 --- a/core/src/entities/Role/index.ts +++ b/core/src/entities/Role/index.ts @@ -1,6 +1,6 @@ -import values from "lodash/values"; +import { values } from "lodash"; -import { RoleNotValidError } from "../../common/errors"; +import { RoleNotValidError } from "../../common/functionalErrors"; import matchesName from "./matchesName"; import matchesUrlMatcher from "./matchesUrlMatcher"; import separatorChar from "./separatorChar"; diff --git a/core/src/index.ts b/core/src/index.ts index dea195d4..0f604d3b 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,5 +1,6 @@ // Errors -export * from "./common/errors"; +export * from "./common/functionalErrors"; +export { default as UnexpectedError } from "./common/UnexpectedError"; // Usecases export { default as Usecase } from "./common/Usecase"; @@ -7,12 +8,14 @@ export { default as CheckHealth } from "./usecases/CheckHealth"; export { default as CreateApp } from "./usecases/CreateApp"; export { default as CreateBundle } from "./usecases/CreateBundle"; export { default as CreateEntrypoint } from "./usecases/CreateEntrypoint"; +export { default as CreateExternalCache } from "./usecases/CreateExternalCache"; export { default as CreateGroup } from "./usecases/CreateGroup"; export { default as CreateRootUserAndGroup } from "./usecases/CreateRootUserAndGroup"; export { default as CreateUser } from "./usecases/CreateUser"; export { default as DeleteApp } from "./usecases/DeleteApp"; export { default as DeleteBundlesByNameAndTag } from "./usecases/DeleteBundlesByNameAndTag"; export { default as DeleteEntrypoint } from "./usecases/DeleteEntrypoint"; +export { default as DeleteExternalCache } from "./usecases/DeleteExternalCache"; export { default as DeleteGroup } from "./usecases/DeleteGroup"; export { default as DeleteUser } from "./usecases/DeleteUser"; export { default as DeployBundle } from "./usecases/DeployBundle"; @@ -26,14 +29,19 @@ export { default as GetBundleTagsByBundleName } from "./usecases/GetBundleTagsBy export { default as GetCurrentUser } from "./usecases/GetCurrentUser"; export { default as GetEntrypoint } from "./usecases/GetEntrypoint"; export { default as GetEntrypointsByAppId } from "./usecases/GetEntrypointsByAppId"; +export { default as GetExternalCache } from "./usecases/GetExternalCache"; +export { default as GetExternalCaches } from "./usecases/GetExternalCaches"; export { default as GetGroup } from "./usecases/GetGroup"; export { default as GetGroups } from "./usecases/GetGroups"; export { default as GetOperationLogs } from "./usecases/GetOperationLogs"; +export { default as GetSupportedExternalCacheTypes } from "./usecases/GetSupportedExternalCacheTypes"; export { default as GetUser } from "./usecases/GetUser"; export { default as GetUsers } from "./usecases/GetUsers"; +export { default as PurgeExternalCache } from "./usecases/PurgeExternalCache"; export { default as RespondToEndpointRequest } from "./usecases/RespondToEndpointRequest"; export { default as UpdateApp } from "./usecases/UpdateApp"; export { default as UpdateEntrypoint } from "./usecases/UpdateEntrypoint"; +export { default as UpdateExternalCache } from "./usecases/UpdateExternalCache"; export { default as UpdateGroup } from "./usecases/UpdateGroup"; export { default as UpdateUser } from "./usecases/UpdateUser"; @@ -41,9 +49,12 @@ export { default as UpdateUser } from "./usecases/UpdateUser"; export { default as IAppsStorage } from "./dependencies/IAppsStorage"; export { default as IArchiver } from "./dependencies/IArchiver"; export { default as IAuthenticationStrategy } from "./dependencies/IAuthenticationStrategy"; +export { default as IExternalCacheService } from "./dependencies/IExternalCacheService"; export { default as IBundlesStorage } from "./dependencies/IBundlesStorage"; export { default as IEntrypointsStorage } from "./dependencies/IEntrypointsStorage"; +export { default as IExternalCachesStorage } from "./dependencies/IExternalCachesStorage"; export { default as IGroupsStorage } from "./dependencies/IGroupsStorage"; +export { default as ILogger } from "./dependencies/ILogger"; export { default as IOperationLogsStorage } from "./dependencies/IOperationLogsStorage"; export { default as IRequestContext } from "./dependencies/IRequestContext"; export { default as IStorages } from "./dependencies/IStorages"; @@ -74,6 +85,12 @@ export { IEntrypoint, isEntrypointUrlMatcherValid } from "./entities/Entrypoint"; +export { + IExternalCache, + IExternalCacheType, + isExternalCacheConfigurationValid, + isExternalCacheDomainValid +} from "./entities/ExternalCache"; export { IFile } from "./entities/File"; export { IGroup } from "./entities/Group"; export { IHealthCheckResult } from "./entities/HealthCheckResult"; diff --git a/core/src/services/Authenticator.ts b/core/src/services/Authenticator.ts index 2d8e2186..28399a9d 100644 --- a/core/src/services/Authenticator.ts +++ b/core/src/services/Authenticator.ts @@ -1,15 +1,40 @@ import { reduce } from "bluebird"; +import { NoUserCorrespondingToIdpUserError } from "../common/functionalErrors"; import IAuthenticationStrategy from "../dependencies/IAuthenticationStrategy"; -import { IIdpUser } from "../entities/User"; +import IUsersStorage from "../dependencies/IUsersStorage"; +import { IIdpUser, IUserWithRoles } from "../entities/User"; export default class Authenticator { constructor( + private users: IUsersStorage, private authenticationStrategies: IAuthenticationStrategy[], + private enforceAuth: boolean, private authToken: string | null ) {} - async getIdpUser(): Promise { + async getUser(): Promise { + if (!this.enforceAuth) { + return null; + } + + const idpUser = await this.getIdpUser(); + if (!idpUser) { + return null; + } + + const user = await this.users.findOneWithRolesByIdpAndIdpId( + idpUser.idp, + idpUser.id + ); + if (!user) { + throw new NoUserCorrespondingToIdpUserError(idpUser); + } + + return user; + } + + private async getIdpUser(): Promise { if (!this.authToken) { return null; } diff --git a/core/src/services/Authorizer.ts b/core/src/services/Authorizer.ts index 97aff56f..ddab30d9 100644 --- a/core/src/services/Authorizer.ts +++ b/core/src/services/Authorizer.ts @@ -1,83 +1,75 @@ import { AuthenticationRequiredError, - MissingRoleError, - NoUserCorrespondingToIdpUserError -} from "../common/errors"; -import IUsersStorage from "../dependencies/IUsersStorage"; + MissingRoleError +} from "../common/functionalErrors"; import { oneOfRolesMatchesRole, RoleName, RoleTuple } from "../entities/Role"; -import { IUser, IUserWithRoles } from "../entities/User"; -import Authenticator from "./Authenticator"; +import { IUserWithRoles } from "../entities/User"; export default class Authorizer { - private currentUser: IUserWithRoles | null = null; + private user: IUserWithRoles | null = null; + constructor(private enforceAuth: boolean) {} - constructor( - private users: IUsersStorage, - private authenticator: Authenticator, - private enforceAuth: boolean - ) {} + _setUser(user: IUserWithRoles | null): void { + this.user = user; + } - // Misc - async canSeeHealtCheckDetails(): Promise { + // Health checks + canSeeHealtCheckDetails(): boolean { try { - await this.ensureAuthenticated(); + this.ensureAuthenticated(); return true; } catch { return false; } } - async getCurrentUser(): Promise { - await this.ensureAuthenticated(); - return this.currentUser; - } // Apps - ensureCanCreateApp(): Promise { - return this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + ensureCanCreateApp(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); } - ensureCanUpdateApp(appName: string): Promise { - return this.ensureAuthorized( + ensureCanUpdateApp(appName: string): void { + this.ensureAuthorized( () => this.matchesRole([RoleName.Root]) || this.matchesRole([RoleName.AppManager, appName]) ); } - ensureCanDeleteApp(appName: string): Promise { - return this.ensureAuthorized( + ensureCanDeleteApp(appName: string): void { + this.ensureAuthorized( () => this.matchesRole([RoleName.Root]) || this.matchesRole([RoleName.AppManager, appName]) ); } - ensureCanGetApps(): Promise { - return this.ensureAuthenticated(); + ensureCanGetApps(): void { + this.ensureAuthenticated(); } // Bundles - ensureCanCreateBundle(bundleName: string): Promise { - return this.ensureAuthorized( + ensureCanCreateBundle(bundleName: string): void { + this.ensureAuthorized( () => this.matchesRole([RoleName.Root]) || this.matchesRole([RoleName.BundleManager, bundleName]) ); } - ensureCanDeleteBundles(bundlesName: string): Promise { - return this.ensureAuthorized( + ensureCanDeleteBundles(bundlesName: string): void { + this.ensureAuthorized( () => this.matchesRole([RoleName.Root]) || this.matchesRole([RoleName.BundleManager, bundlesName]) ); } - ensureCanGetBundles(): Promise { - return this.ensureAuthenticated(); + ensureCanGetBundles(): void { + this.ensureAuthenticated(); } // Entrypoints ensureCanCreateEntrypoint( entrypointUrlMatcher: string, entrypointAppName: string - ): Promise { - return this.ensureAuthorized( + ): void { + this.ensureAuthorized( () => this.matchesRole([RoleName.Root]) || (this.matchesRole([RoleName.AppManager, entrypointAppName]) && @@ -87,8 +79,8 @@ export default class Authorizer { ])) ); } - ensureCanUpdateEntrypoint(entrypointUrlMatcher: string): Promise { - return this.ensureAuthorized( + ensureCanUpdateEntrypoint(entrypointUrlMatcher: string): void { + this.ensureAuthorized( () => this.matchesRole([RoleName.Root]) || this.matchesRole([ @@ -97,8 +89,8 @@ export default class Authorizer { ]) ); } - ensureCanDeleteEntrypoint(entrypointUrlMatcher: string): Promise { - return this.ensureAuthorized( + ensureCanDeleteEntrypoint(entrypointUrlMatcher: string): void { + this.ensureAuthorized( () => this.matchesRole([RoleName.Root]) || this.matchesRole([ @@ -107,80 +99,74 @@ export default class Authorizer { ]) ); } - ensureCanGetEntrypoints(): Promise { - return this.ensureAuthenticated(); + ensureCanGetEntrypoints(): void { + this.ensureAuthenticated(); + } + + // External caches + ensureCanCreateExternalCache(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + } + ensureCanUpdateExternalCache(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + } + ensureCanDeleteExternalCache(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + } + ensureCanPurgeExternalCache(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + } + ensureCanGetExternalCaches(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); } // Groups - ensureCanCreateGroup(): Promise { - return this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + ensureCanCreateGroup(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); } - ensureCanUpdateGroup(): Promise { - return this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + ensureCanUpdateGroup(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); } - ensureCanDeleteGroup(): Promise { - return this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + ensureCanDeleteGroup(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); } - ensureCanGetGroups(): Promise { - return this.ensureAuthenticated(); + ensureCanGetGroups(): void { + this.ensureAuthenticated(); } // Operation logs - ensureCanGetOperationLogs(): Promise { - return this.ensureAuthenticated(); + ensureCanGetOperationLogs(): void { + this.ensureAuthenticated(); } // Users - ensureCanCreateUser(): Promise { - return this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + ensureCanCreateUser(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); } - ensureCanUpdateUser(): Promise { - return this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + ensureCanUpdateUser(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); } - ensureCanDeleteUser(): Promise { - return this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); + ensureCanDeleteUser(): void { + this.ensureAuthorized(() => this.matchesRole([RoleName.Root])); } - ensureCanGetUsers(): Promise { - return this.ensureAuthenticated(); + ensureCanGetUsers(): void { + this.ensureAuthenticated(); } - private async ensureAuthenticated(): Promise { - if ( - !this.enforceAuth || - // If there is a user, this method was already called and made its - // checks, no need to re-do them - this.currentUser - ) { - return; - } - - const idpUser = await this.authenticator.getIdpUser(); - if (!idpUser) { + private ensureAuthenticated(): void { + if (this.enforceAuth && !this.user) { throw new AuthenticationRequiredError(); } - - this.currentUser = await this.users.findOneWithRolesByIdpAndIdpId( - idpUser.idp, - idpUser.id - ); - if (!this.currentUser) { - throw new NoUserCorrespondingToIdpUserError(idpUser); - } } - private async ensureAuthorized( - hasRequiredRoles: () => boolean - ): Promise { - if (!this.enforceAuth) { - return; - } - - await this.ensureAuthenticated(); - - if (hasRequiredRoles && !hasRequiredRoles()) { - throw new MissingRoleError(); + private ensureAuthorized(hasRequiredRoles: () => boolean): void { + if (this.enforceAuth) { + this.ensureAuthenticated(); + if (hasRequiredRoles && !hasRequiredRoles()) { + throw new MissingRoleError(); + } } } private matchesRole(targetRole: RoleTuple): boolean { - return oneOfRolesMatchesRole(this.currentUser!.roles, targetRole); + return oneOfRolesMatchesRole(this.user!.roles, targetRole); } } diff --git a/core/src/services/OperationLogger.ts b/core/src/services/OperationLogger.ts index e0ea9759..a38b3ec6 100644 --- a/core/src/services/OperationLogger.ts +++ b/core/src/services/OperationLogger.ts @@ -1,24 +1,25 @@ import generateId from "../common/generateId"; import IOperationLogsStorage from "../dependencies/IOperationLogsStorage"; import { IOperationLog, Operation } from "../entities/OperationLog"; -import Authorizer from "./Authorizer"; +import { IUser } from "../entities/User"; export default class OperationLogger { - constructor( - private operationLogs: IOperationLogsStorage, - private authorizer: Authorizer - ) {} + private user: IUser | null = null; + constructor(private operationLogs: IOperationLogsStorage) {} + + _setUser(user: IUser | null): void { + this.user = user; + } async logOperation( operation: Operation, parameters: IOperationLog["parameters"] ): Promise { - const user = await this.authorizer.getCurrentUser(); await this.operationLogs.createOne({ id: generateId(), operation: operation, parameters: parameters, - performedBy: user ? user.id : "anonymous", + performedBy: this.user?.id ?? "anonymous", performedAt: new Date() }); } diff --git a/core/src/usecases/CheckHealth.ts b/core/src/usecases/CheckHealth.ts index 1399f053..f7923259 100644 --- a/core/src/usecases/CheckHealth.ts +++ b/core/src/usecases/CheckHealth.ts @@ -1,12 +1,15 @@ import Usecase from "../common/Usecase"; import { IHealthCheckResult } from "../entities/HealthCheckResult"; -export default class CheckHealth extends Usecase { - async exec(): Promise { +type Arguments = []; +type ReturnValue = IHealthCheckResult; + +export default class CheckHealth extends Usecase { + protected async _exec(): Promise { const healthCheckResult = await this.storages.checkHealth(); return { isHealthy: healthCheckResult.isHealthy, - details: (await this.authorizer.canSeeHealtCheckDetails()) + details: this.authorizer.canSeeHealtCheckDetails() ? healthCheckResult.details : undefined }; diff --git a/core/src/usecases/CreateApp.ts b/core/src/usecases/CreateApp.ts index 67a09313..b0d4317a 100644 --- a/core/src/usecases/CreateApp.ts +++ b/core/src/usecases/CreateApp.ts @@ -1,4 +1,4 @@ -import { ConflictingAppError } from "../common/errors"; +import { ConflictingAppError } from "../common/functionalErrors"; import generateId from "../common/generateId"; import Usecase from "../common/Usecase"; import { IApp, validateAppName } from "../entities/App"; @@ -8,13 +8,18 @@ import { } from "../entities/Configuration"; import { Operation } from "../entities/OperationLog"; -export default class CreateApp extends Usecase { - async exec(partial: { +type Arguments = [ + { name: string; defaultConfiguration?: IConfiguration; - }): Promise { + } +]; +type ReturnValue = IApp; + +export default class CreateApp extends Usecase { + protected async _exec(partial: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanCreateApp(); + this.authorizer.ensureCanCreateApp(); // Validate name and defaultConfiguration validateAppName(partial.name); diff --git a/core/src/usecases/CreateBundle.ts b/core/src/usecases/CreateBundle.ts index 9825acc2..866ae68a 100644 --- a/core/src/usecases/CreateBundle.ts +++ b/core/src/usecases/CreateBundle.ts @@ -3,14 +3,14 @@ import md5 from "md5"; import { isMatch } from "micromatch"; import { getType } from "mime"; -import { BundleFallbackAssetNotFoundError } from "../common/errors"; +import { BundleFallbackAssetNotFoundError } from "../common/functionalErrors"; import generateId from "../common/generateId"; import Usecase from "../common/Usecase"; import { IBundle, validateBundleNameOrTag } from "../entities/Bundle"; import { Operation } from "../entities/OperationLog"; -export default class CreateBundle extends Usecase { - async exec(partial: { +type Arguments = [ + { name: string; tag: string; description: string; @@ -23,9 +23,14 @@ export default class CreateBundle extends Usecase { [headerName: string]: string; }; }; - }): Promise { + } +]; +type ReturnValue = IBundle; + +export default class CreateBundle extends Usecase { + protected async _exec(partial: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanCreateBundle(partial.name); + this.authorizer.ensureCanCreateBundle(partial.name); // Validate name and tag validateBundleNameOrTag(partial.name, "name"); diff --git a/core/src/usecases/CreateEntrypoint.ts b/core/src/usecases/CreateEntrypoint.ts index bb9440be..7181f52c 100644 --- a/core/src/usecases/CreateEntrypoint.ts +++ b/core/src/usecases/CreateEntrypoint.ts @@ -2,7 +2,7 @@ import { AppNotFoundError, BundleNotFoundError, ConflictingEntrypointError -} from "../common/errors"; +} from "../common/functionalErrors"; import generateId from "../common/generateId"; import Usecase from "../common/Usecase"; import { @@ -15,14 +15,19 @@ import { } from "../entities/Entrypoint"; import { Operation } from "../entities/OperationLog"; -export default class CreateEntrypoint extends Usecase { - async exec(partial: { +type Arguments = [ + { appId: string; bundleId?: string | null; redirectTo?: string | null; urlMatcher: string; configuration?: IConfiguration | null; - }): Promise { + } +]; +type ReturnValue = IEntrypoint; + +export default class CreateEntrypoint extends Usecase { + protected async _exec(partial: Arguments[0]): Promise { // Ensure the linked app exists const linkedApp = await this.storages.apps.findOne(partial.appId); if (!linkedApp) { @@ -30,7 +35,7 @@ export default class CreateEntrypoint extends Usecase { } // Auth check - await this.authorizer.ensureCanCreateEntrypoint( + this.authorizer.ensureCanCreateEntrypoint( partial.urlMatcher, linkedApp.name ); diff --git a/core/src/usecases/CreateExternalCache.ts b/core/src/usecases/CreateExternalCache.ts new file mode 100644 index 00000000..b39a8450 --- /dev/null +++ b/core/src/usecases/CreateExternalCache.ts @@ -0,0 +1,73 @@ +import { ConflictingExternalCacheError } from "../common/functionalErrors"; +import generateId from "../common/generateId"; +import getSupportedExternalCacheTypes from "../common/getSupportedExternalCacheTypes"; +import Usecase from "../common/Usecase"; +import { + getMatchingExternalCacheType, + IExternalCache, + validateExternalCacheConfiguration, + validateExternalCacheDomain, + validateExternalCacheType +} from "../entities/ExternalCache"; +import { Operation } from "../entities/OperationLog"; + +type Arguments = [ + { + domain: string; + type: string; + configuration: IExternalCache["configuration"]; + } +]; +type ReturnValue = IExternalCache; + +export default class CreateExternalCache extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec(partial: Arguments[0]): Promise { + // Auth check + this.authorizer.ensureCanCreateExternalCache(); + + // Validate input properties + const supportedExternalCacheTypes = getSupportedExternalCacheTypes( + this.externalCacheServices + ); + validateExternalCacheType(partial.type, supportedExternalCacheTypes); + validateExternalCacheDomain(partial.domain); + validateExternalCacheConfiguration( + partial.configuration, + getMatchingExternalCacheType( + supportedExternalCacheTypes, + partial.type + )! + ); + + // Ensure no externalCache with the same domain exists + const conflictingExternalCacheExists = await this.storages.externalCaches.oneExistsWithDomain( + partial.domain + ); + if (conflictingExternalCacheExists) { + throw new ConflictingExternalCacheError(partial.domain); + } + + // Create the externalCache + const now = new Date(); + const createdExternalCache = await this.storages.externalCaches.createOne( + { + id: generateId(), + domain: partial.domain, + type: partial.type, + configuration: partial.configuration, + createdAt: now, + updatedAt: now + } + ); + + // Log the operation + await this.operationLogger.logOperation(Operation.CreateExternalCache, { + createdExternalCache + }); + + return createdExternalCache; + } +} diff --git a/core/src/usecases/CreateGroup.ts b/core/src/usecases/CreateGroup.ts index 93c78937..4b756354 100644 --- a/core/src/usecases/CreateGroup.ts +++ b/core/src/usecases/CreateGroup.ts @@ -1,14 +1,22 @@ -import { ConflictingGroupError } from "../common/errors"; +import { ConflictingGroupError } from "../common/functionalErrors"; import generateId from "../common/generateId"; import Usecase from "../common/Usecase"; import { IGroup } from "../entities/Group"; import { Operation } from "../entities/OperationLog"; import { validateRole } from "../entities/Role"; -export default class CreateGroup extends Usecase { - async exec(partial: { name: string; roles: string[] }): Promise { +type Arguments = [ + { + name: string; + roles: string[]; + } +]; +type ReturnValue = IGroup; + +export default class CreateGroup extends Usecase { + protected async _exec(partial: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanCreateGroup(); + this.authorizer.ensureCanCreateGroup(); // Validate roles partial.roles.forEach(validateRole); diff --git a/core/src/usecases/CreateRootUserAndGroup.ts b/core/src/usecases/CreateRootUserAndGroup.ts index 82530fcd..d76f39a0 100644 --- a/core/src/usecases/CreateRootUserAndGroup.ts +++ b/core/src/usecases/CreateRootUserAndGroup.ts @@ -1,5 +1,5 @@ import generateId from "../common/generateId"; -import IStorages from "../dependencies/IStorages"; +import Usecase from "../common/Usecase"; import { RoleName } from "../entities/Role"; import { UserType } from "../entities/User"; @@ -7,10 +7,14 @@ export const ROOT_GROUP_NAME = "root"; export const ROOT_USER_IDP_ID = "root"; export const ROOT_USER_NAME = "root"; -export default class CreateRootUserAndGroup { - constructor(private storages: IStorages) {} +type Arguments = [string]; +type ReturnValue = void; - async exec(idp: string): Promise { +export default class CreateRootUserAndGroup extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec(idp: Arguments[0]): Promise { const now = new Date(); // Create the root group if it doesn't exist diff --git a/core/src/usecases/CreateUser.ts b/core/src/usecases/CreateUser.ts index 6bcf59d2..1c6f2a6d 100644 --- a/core/src/usecases/CreateUser.ts +++ b/core/src/usecases/CreateUser.ts @@ -1,21 +1,29 @@ import { isEmpty } from "lodash"; -import { ConflictingUserError, SomeGroupNotFoundError } from "../common/errors"; +import { + ConflictingUserError, + SomeGroupNotFoundError +} from "../common/functionalErrors"; import generateId from "../common/generateId"; import Usecase from "../common/Usecase"; import { Operation } from "../entities/OperationLog"; import { IUser, UserType } from "../entities/User"; -export default class CreateUser extends Usecase { - async exec(partial: { +type Arguments = [ + { idp: string; idpId: string; type: UserType; name: string; groupsIds: string[]; - }): Promise { + } +]; +type ReturnValue = IUser; + +export default class CreateUser extends Usecase { + protected async _exec(partial: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanCreateUser(); + this.authorizer.ensureCanCreateUser(); // Ensure no user with the same idp / idpId combination exists const conflictingUserExists = await this.storages.users.oneExistsWithIdpAndIdpId( diff --git a/core/src/usecases/DeleteApp.ts b/core/src/usecases/DeleteApp.ts index dde3c607..b330bf63 100644 --- a/core/src/usecases/DeleteApp.ts +++ b/core/src/usecases/DeleteApp.ts @@ -1,9 +1,15 @@ -import { AppHasEntrypointsError, AppNotFoundError } from "../common/errors"; +import { + AppHasEntrypointsError, + AppNotFoundError +} from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { Operation } from "../entities/OperationLog"; -export default class DeleteApp extends Usecase { - async exec(id: string): Promise { +type Arguments = [string]; +type ReturnValue = void; + +export default class DeleteApp extends Usecase { + protected async _exec(id: Arguments[0]): Promise { const toBeDeletedApp = await this.storages.apps.findOne(id); // Ensure the app exists @@ -12,7 +18,7 @@ export default class DeleteApp extends Usecase { } // Auth check - await this.authorizer.ensureCanDeleteApp(toBeDeletedApp.name); + this.authorizer.ensureCanDeleteApp(toBeDeletedApp.name); // Ensure the app has no linked entrypoints const hasLinkedEntrypoints = await this.storages.entrypoints.anyExistsWithAppId( diff --git a/core/src/usecases/DeleteBundlesByNameAndTag.ts b/core/src/usecases/DeleteBundlesByNameAndTag.ts index 11494be1..7960f3fa 100644 --- a/core/src/usecases/DeleteBundlesByNameAndTag.ts +++ b/core/src/usecases/DeleteBundlesByNameAndTag.ts @@ -1,13 +1,22 @@ import { map } from "lodash"; -import { BundlesInUseError } from "../common/errors"; +import { BundlesInUseError } from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { Operation } from "../entities/OperationLog"; -export default class DeleteBundlesByNameAndTag extends Usecase { - async exec(name: string, tag: string): Promise { +type Arguments = [string, string]; +type ReturnValue = void; + +export default class DeleteBundlesByNameAndTag extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec( + name: Arguments[0], + tag: Arguments[1] + ): Promise { // Auth check - await this.authorizer.ensureCanDeleteBundles(name); + this.authorizer.ensureCanDeleteBundles(name); // Find bundles to be deleted const toBeDeletedBundles = await this.storages.bundles.findManyByNameAndTag( diff --git a/core/src/usecases/DeleteEntrypoint.ts b/core/src/usecases/DeleteEntrypoint.ts index 8b393898..70ab785d 100644 --- a/core/src/usecases/DeleteEntrypoint.ts +++ b/core/src/usecases/DeleteEntrypoint.ts @@ -1,9 +1,12 @@ -import { EntrypointNotFoundError } from "../common/errors"; +import { EntrypointNotFoundError } from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { Operation } from "../entities/OperationLog"; -export default class DeleteEntrypoint extends Usecase { - async exec(id: string): Promise { +type Arguments = [string]; +type ReturnValue = void; + +export default class DeleteEntrypoint extends Usecase { + protected async _exec(id: Arguments[0]): Promise { const toBeDeletedEntrypoint = await this.storages.entrypoints.findOne( id ); @@ -14,7 +17,7 @@ export default class DeleteEntrypoint extends Usecase { } // Auth check - await this.authorizer.ensureCanDeleteEntrypoint( + this.authorizer.ensureCanDeleteEntrypoint( toBeDeletedEntrypoint.urlMatcher ); diff --git a/core/src/usecases/DeleteExternalCache.ts b/core/src/usecases/DeleteExternalCache.ts new file mode 100644 index 00000000..4346c609 --- /dev/null +++ b/core/src/usecases/DeleteExternalCache.ts @@ -0,0 +1,33 @@ +import { ExternalCacheNotFoundError } from "../common/functionalErrors"; +import Usecase from "../common/Usecase"; +import { Operation } from "../entities/OperationLog"; + +type Arguments = [string]; +type ReturnValue = void; + +export default class DeleteExternalCache extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec(id: Arguments[0]): Promise { + const toBeDeletedExternalCache = await this.storages.externalCaches.findOne( + id + ); + + // Ensure the externalCache exists + if (!toBeDeletedExternalCache) { + throw new ExternalCacheNotFoundError(id); + } + + // Auth check + this.authorizer.ensureCanDeleteExternalCache(); + + // Delete the externalCache + await this.storages.externalCaches.deleteOne(id); + + // Log the operation + await this.operationLogger.logOperation(Operation.DeleteExternalCache, { + deletedExternalCache: toBeDeletedExternalCache + }); + } +} diff --git a/core/src/usecases/DeleteGroup.ts b/core/src/usecases/DeleteGroup.ts index 7b952164..6d5e4bd7 100644 --- a/core/src/usecases/DeleteGroup.ts +++ b/core/src/usecases/DeleteGroup.ts @@ -1,11 +1,17 @@ -import { GroupHasUsersError, GroupNotFoundError } from "../common/errors"; +import { + GroupHasUsersError, + GroupNotFoundError +} from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { Operation } from "../entities/OperationLog"; -export default class DeleteUser extends Usecase { - async exec(id: string): Promise { +type Arguments = [string]; +type ReturnValue = void; + +export default class DeleteUser extends Usecase { + protected async _exec(id: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanDeleteGroup(); + this.authorizer.ensureCanDeleteGroup(); const toBeDeletedGroup = await this.storages.groups.findOne(id); diff --git a/core/src/usecases/DeleteUser.ts b/core/src/usecases/DeleteUser.ts index d7c570d6..19e49598 100644 --- a/core/src/usecases/DeleteUser.ts +++ b/core/src/usecases/DeleteUser.ts @@ -1,11 +1,14 @@ -import { UserNotFoundError } from "../common/errors"; +import { UserNotFoundError } from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { Operation } from "../entities/OperationLog"; -export default class DeleteUser extends Usecase { - async exec(id: string): Promise { +type Arguments = [string]; +type ReturnValue = void; + +export default class DeleteUser extends Usecase { + protected async _exec(id: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanDeleteUser(); + this.authorizer.ensureCanDeleteUser(); const toBeDeletedUser = await this.storages.users.findOne(id); diff --git a/core/src/usecases/DeployBundle.ts b/core/src/usecases/DeployBundle.ts index 23d3629d..673fc7ed 100644 --- a/core/src/usecases/DeployBundle.ts +++ b/core/src/usecases/DeployBundle.ts @@ -1,19 +1,24 @@ import { BundleNotFoundError, EntrypointMismatchedAppIdError -} from "../common/errors"; +} from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { splitNameTagCombination } from "../entities/Bundle"; import CreateApp from "./CreateApp"; import CreateEntrypoint from "./CreateEntrypoint"; import UpdateEntrypoint from "./UpdateEntrypoint"; -export default class DeployBundle extends Usecase { - async exec(options: { +type Arguments = [ + { bundleNameTagCombination: string; appName: string; entrypointUrlMatcher: string; - }): Promise { + } +]; +type ReturnValue = void; + +export default class DeployBundle extends Usecase { + protected async _exec(options: Arguments[0]): Promise { const { bundleNameTagCombination, appName, diff --git a/core/src/usecases/GetApp.ts b/core/src/usecases/GetApp.ts index a32dbf79..3de333b0 100644 --- a/core/src/usecases/GetApp.ts +++ b/core/src/usecases/GetApp.ts @@ -1,11 +1,14 @@ -import { AppNotFoundError } from "../common/errors"; +import { AppNotFoundError } from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { IApp } from "../entities/App"; -export default class GetApp extends Usecase { - async exec(id: string): Promise { +type Arguments = [string]; +type ReturnValue = IApp; + +export default class GetApp extends Usecase { + protected async _exec(id: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanGetApps(); + this.authorizer.ensureCanGetApps(); const app = await this.storages.apps.findOne(id); diff --git a/core/src/usecases/GetApps.ts b/core/src/usecases/GetApps.ts index 4754279a..11af1504 100644 --- a/core/src/usecases/GetApps.ts +++ b/core/src/usecases/GetApps.ts @@ -1,10 +1,13 @@ import Usecase from "../common/Usecase"; import { IApp } from "../entities/App"; -export default class GetApps extends Usecase { - async exec(): Promise { +type Arguments = []; +type ReturnValue = IApp[]; + +export default class GetApps extends Usecase { + protected async _exec(): Promise { // Auth check - await this.authorizer.ensureCanGetApps(); + this.authorizer.ensureCanGetApps(); return this.storages.apps.findMany(); } diff --git a/core/src/usecases/GetBundle.ts b/core/src/usecases/GetBundle.ts index b7db4cb6..26291f83 100644 --- a/core/src/usecases/GetBundle.ts +++ b/core/src/usecases/GetBundle.ts @@ -1,11 +1,14 @@ -import { BundleNotFoundError } from "../common/errors"; +import { BundleNotFoundError } from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { IBaseBundle } from "../entities/Bundle"; -export default class GetBundle extends Usecase { - async exec(id: string): Promise { +type Arguments = [string]; +type ReturnValue = IBaseBundle; + +export default class GetBundle extends Usecase { + protected async _exec(id: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanGetBundles(); + this.authorizer.ensureCanGetBundles(); const bundle = await this.storages.bundles.findOne(id); diff --git a/core/src/usecases/GetBundleNames.ts b/core/src/usecases/GetBundleNames.ts index c3e35a48..23730820 100644 --- a/core/src/usecases/GetBundleNames.ts +++ b/core/src/usecases/GetBundleNames.ts @@ -1,9 +1,12 @@ import Usecase from "../common/Usecase"; -export default class GetBundleNames extends Usecase { - async exec(): Promise { +type Arguments = []; +type ReturnValue = string[]; + +export default class GetBundleNames extends Usecase { + protected async _exec(): Promise { // Auth check - await this.authorizer.ensureCanGetBundles(); + this.authorizer.ensureCanGetBundles(); return this.storages.bundles.findManyNames(); } diff --git a/core/src/usecases/GetBundleTagsByBundleName.ts b/core/src/usecases/GetBundleTagsByBundleName.ts index 24e88039..9ab27be5 100644 --- a/core/src/usecases/GetBundleTagsByBundleName.ts +++ b/core/src/usecases/GetBundleTagsByBundleName.ts @@ -1,9 +1,15 @@ import Usecase from "../common/Usecase"; -export default class GetBundleTagsByBundleName extends Usecase { - async exec(bundleName: string): Promise { +type Arguments = [string]; +type ReturnValue = string[]; + +export default class GetBundleTagsByBundleName extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec(bundleName: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanGetBundles(); + this.authorizer.ensureCanGetBundles(); return this.storages.bundles.findManyTagsByName(bundleName); } diff --git a/core/src/usecases/GetBundles.ts b/core/src/usecases/GetBundles.ts index 8dd8e0f7..5d73df49 100644 --- a/core/src/usecases/GetBundles.ts +++ b/core/src/usecases/GetBundles.ts @@ -1,10 +1,13 @@ import Usecase from "../common/Usecase"; import { IBaseBundle } from "../entities/Bundle"; -export default class GetBundles extends Usecase { - async exec(): Promise { +type Arguments = []; +type ReturnValue = IBaseBundle[]; + +export default class GetBundles extends Usecase { + protected async _exec(): Promise { // Auth check - await this.authorizer.ensureCanGetBundles(); + this.authorizer.ensureCanGetBundles(); return this.storages.bundles.findMany(); } diff --git a/core/src/usecases/GetBundlesByNameAndTag.ts b/core/src/usecases/GetBundlesByNameAndTag.ts index 1df2b0a6..73027b8c 100644 --- a/core/src/usecases/GetBundlesByNameAndTag.ts +++ b/core/src/usecases/GetBundlesByNameAndTag.ts @@ -1,10 +1,19 @@ import Usecase from "../common/Usecase"; import { IBundle } from "../entities/Bundle"; -export default class GetBundlesByNameAndTag extends Usecase { - async exec(name: string, tag: string): Promise { +type Arguments = [string, string]; +type ReturnValue = IBundle[]; + +export default class GetBundlesByNameAndTag extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec( + name: Arguments[0], + tag: Arguments[1] + ): Promise { // Auth check - await this.authorizer.ensureCanGetBundles(); + this.authorizer.ensureCanGetBundles(); return this.storages.bundles.findManyByNameAndTag(name, tag); } diff --git a/core/src/usecases/GetCurrentUser.ts b/core/src/usecases/GetCurrentUser.ts index 4f5e9936..ed427a33 100644 --- a/core/src/usecases/GetCurrentUser.ts +++ b/core/src/usecases/GetCurrentUser.ts @@ -1,8 +1,11 @@ import Usecase from "../common/Usecase"; -import { IUser } from "../entities/User"; +import { IUserWithRoles } from "../entities/User"; -export default class GetCurrentUser extends Usecase { - exec(): Promise { - return this.authorizer.getCurrentUser(); +type Arguments = []; +type ReturnValue = IUserWithRoles | null; + +export default class GetCurrentUser extends Usecase { + protected async _exec(): Promise { + return this.user; } } diff --git a/core/src/usecases/GetEntrypoint.ts b/core/src/usecases/GetEntrypoint.ts index fec531c5..cec72965 100644 --- a/core/src/usecases/GetEntrypoint.ts +++ b/core/src/usecases/GetEntrypoint.ts @@ -1,11 +1,14 @@ -import { EntrypointNotFoundError } from "../common/errors"; +import { EntrypointNotFoundError } from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { IEntrypoint } from "../entities/Entrypoint"; -export default class GetEntrypoint extends Usecase { - async exec(id: string): Promise { +type Arguments = [string]; +type ReturnValue = IEntrypoint; + +export default class GetEntrypoint extends Usecase { + protected async _exec(id: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanGetEntrypoints(); + this.authorizer.ensureCanGetEntrypoints(); const entrypoint = await this.storages.entrypoints.findOne(id); diff --git a/core/src/usecases/GetEntrypointsByAppId.ts b/core/src/usecases/GetEntrypointsByAppId.ts index 640f152e..3d4b2583 100644 --- a/core/src/usecases/GetEntrypointsByAppId.ts +++ b/core/src/usecases/GetEntrypointsByAppId.ts @@ -1,11 +1,17 @@ -import { AppNotFoundError } from "../common/errors"; +import { AppNotFoundError } from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { IEntrypoint } from "../entities/Entrypoint"; -export default class GetEntrypointsByAppId extends Usecase { - async exec(appId: string): Promise { +type Arguments = [string]; +type ReturnValue = IEntrypoint[]; + +export default class GetEntrypointsByAppId extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec(appId: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanGetEntrypoints(); + this.authorizer.ensureCanGetEntrypoints(); // Ensure the app with the specified id exists const appExists = await this.storages.apps.oneExistsWithId(appId); diff --git a/core/src/usecases/GetExternalCache.ts b/core/src/usecases/GetExternalCache.ts new file mode 100644 index 00000000..ab7c1df5 --- /dev/null +++ b/core/src/usecases/GetExternalCache.ts @@ -0,0 +1,22 @@ +import { ExternalCacheNotFoundError } from "../common/functionalErrors"; +import Usecase from "../common/Usecase"; +import { IExternalCache } from "../entities/ExternalCache"; + +type Arguments = [string]; +type ReturnValue = IExternalCache; + +export default class GetExternalCache extends Usecase { + protected async _exec(id: Arguments[0]): Promise { + // Auth check + this.authorizer.ensureCanGetExternalCaches(); + + const externalCache = await this.storages.externalCaches.findOne(id); + + // Ensure the externalCache exists + if (!externalCache) { + throw new ExternalCacheNotFoundError(id); + } + + return externalCache; + } +} diff --git a/core/src/usecases/GetExternalCaches.ts b/core/src/usecases/GetExternalCaches.ts new file mode 100644 index 00000000..77b2624c --- /dev/null +++ b/core/src/usecases/GetExternalCaches.ts @@ -0,0 +1,14 @@ +import Usecase from "../common/Usecase"; +import { IExternalCache } from "../entities/ExternalCache"; + +type Arguments = []; +type ReturnValue = IExternalCache[]; + +export default class GetExternalCaches extends Usecase { + protected async _exec(): Promise { + // Auth check + this.authorizer.ensureCanGetExternalCaches(); + + return this.storages.externalCaches.findMany(); + } +} diff --git a/core/src/usecases/GetGroup.ts b/core/src/usecases/GetGroup.ts index 487f03ba..62e1ceae 100644 --- a/core/src/usecases/GetGroup.ts +++ b/core/src/usecases/GetGroup.ts @@ -1,11 +1,14 @@ -import { GroupNotFoundError } from "../common/errors"; +import { GroupNotFoundError } from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { IGroup } from "../entities/Group"; -export default class GetGroup extends Usecase { - async exec(id: string): Promise { +type Arguments = [string]; +type ReturnValue = IGroup; + +export default class GetGroup extends Usecase { + protected async _exec(id: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanGetGroups(); + this.authorizer.ensureCanGetGroups(); const group = await this.storages.groups.findOne(id); diff --git a/core/src/usecases/GetGroups.ts b/core/src/usecases/GetGroups.ts index 1229fc5c..91945e14 100644 --- a/core/src/usecases/GetGroups.ts +++ b/core/src/usecases/GetGroups.ts @@ -1,10 +1,13 @@ import Usecase from "../common/Usecase"; import { IGroup } from "../entities/Group"; -export default class GetGroups extends Usecase { - async exec(): Promise { +type Arguments = []; +type ReturnValue = IGroup[]; + +export default class GetGroups extends Usecase { + protected async _exec(): Promise { // Auth check - await this.authorizer.ensureCanGetGroups(); + this.authorizer.ensureCanGetGroups(); return this.storages.groups.findMany(); } diff --git a/core/src/usecases/GetOperationLogs.ts b/core/src/usecases/GetOperationLogs.ts index 5f4219a0..c22a4ad1 100644 --- a/core/src/usecases/GetOperationLogs.ts +++ b/core/src/usecases/GetOperationLogs.ts @@ -1,10 +1,13 @@ import Usecase from "../common/Usecase"; import { IOperationLog } from "../entities/OperationLog"; -export default class GetOperationLogs extends Usecase { - async exec(): Promise { +type Arguments = []; +type ReturnValue = IOperationLog[]; + +export default class GetOperationLogs extends Usecase { + protected async _exec(): Promise { // Auth check - await this.authorizer.ensureCanGetOperationLogs(); + this.authorizer.ensureCanGetOperationLogs(); return this.storages.operationLogs.findMany(); } diff --git a/core/src/usecases/GetSupportedExternalCacheTypes.ts b/core/src/usecases/GetSupportedExternalCacheTypes.ts new file mode 100644 index 00000000..b5c8f07d --- /dev/null +++ b/core/src/usecases/GetSupportedExternalCacheTypes.ts @@ -0,0 +1,17 @@ +import getSupportedExternalCacheTypes from "../common/getSupportedExternalCacheTypes"; +import Usecase from "../common/Usecase"; +import { IExternalCacheType } from "../entities/ExternalCache"; + +type Arguments = []; +type ReturnValue = IExternalCacheType[]; + +export default class GetSupportedExternalCacheTypes extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec(): Promise { + this.authorizer.ensureCanGetExternalCaches(); + + return getSupportedExternalCacheTypes(this.externalCacheServices); + } +} diff --git a/core/src/usecases/GetUser.ts b/core/src/usecases/GetUser.ts index d7e96b5f..74769c91 100644 --- a/core/src/usecases/GetUser.ts +++ b/core/src/usecases/GetUser.ts @@ -1,11 +1,14 @@ -import { UserNotFoundError } from "../common/errors"; +import { UserNotFoundError } from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { IUserWithGroups } from "../entities/User"; -export default class GetUser extends Usecase { - async exec(id: string): Promise { +type Arguments = [string]; +type ReturnValue = IUserWithGroups; + +export default class GetUser extends Usecase { + protected async _exec(id: Arguments[0]): Promise { // Auth check - await this.authorizer.ensureCanGetUsers(); + this.authorizer.ensureCanGetUsers(); const user = await this.storages.users.findOneWithGroups(id); diff --git a/core/src/usecases/GetUsers.ts b/core/src/usecases/GetUsers.ts index 63538ffc..83ee3b01 100644 --- a/core/src/usecases/GetUsers.ts +++ b/core/src/usecases/GetUsers.ts @@ -1,10 +1,13 @@ import Usecase from "../common/Usecase"; import { IUser } from "../entities/User"; -export default class GetUsers extends Usecase { - async exec(): Promise { +type Arguments = []; +type ReturnValue = IUser[]; + +export default class GetUsers extends Usecase { + protected async _exec(): Promise { // Auth check - await this.authorizer.ensureCanGetUsers(); + this.authorizer.ensureCanGetUsers(); return this.storages.users.findMany(); } diff --git a/core/src/usecases/PurgeExternalCache.ts b/core/src/usecases/PurgeExternalCache.ts new file mode 100644 index 00000000..0d133017 --- /dev/null +++ b/core/src/usecases/PurgeExternalCache.ts @@ -0,0 +1,46 @@ +import { find } from "lodash"; + +import { + ExternalCacheNotFoundError, + ExternalCacheTypeNotSupportedError +} from "../common/functionalErrors"; +import Usecase from "../common/Usecase"; +import { Operation } from "../entities/OperationLog"; + +type Arguments = [string, string[]]; +type ReturnValue = void; + +export default class PurgeExternalCache extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec( + id: Arguments[0], + paths: Arguments[1] + ): Promise { + this.authorizer.ensureCanPurgeExternalCache(); + + const externalCache = await this.storages.externalCaches.findOne(id); + + if (!externalCache) { + throw new ExternalCacheNotFoundError(id); + } + + const externalCacheService = find(this.externalCacheServices, [ + "externalCacheType.name", + externalCache.type + ]); + + if (!externalCacheService) { + throw new ExternalCacheTypeNotSupportedError(externalCache.type); + } + + await externalCacheService.purge(paths, externalCache.configuration); + + await this.operationLogger.logOperation(Operation.PurgeExternalCache, { + domain: externalCache.domain, + type: externalCache.type, + paths: paths + }); + } +} diff --git a/core/src/usecases/RespondToEndpointRequest/index.ts b/core/src/usecases/RespondToEndpointRequest/index.ts index 0885fb71..f0e7c02a 100644 --- a/core/src/usecases/RespondToEndpointRequest/index.ts +++ b/core/src/usecases/RespondToEndpointRequest/index.ts @@ -3,10 +3,10 @@ import { join } from "path"; import { NoBundleOrRedirectToError, - NoMatchingEntrypointError, - StoragesInconsistencyError -} from "../../common/errors"; + NoMatchingEntrypointError +} from "../../common/functionalErrors"; import removePrefix from "../../common/removePrefix"; +import StoragesInconsistencyError from "../../common/StoragesInconsistencyError"; import Usecase from "../../common/Usecase"; import { IConfiguration } from "../../entities/Configuration"; import { IEndpointRequest } from "../../entities/EndpointRequest"; @@ -20,8 +20,14 @@ import isCanonicalPath from "./isCanonicalPath"; import toAbsolute from "./toAbsolute"; import whitelistInlineScript from "./whitelistInlineScript"; -export default class RespondToEndpointRequest extends Usecase { - async exec(request: IEndpointRequest): Promise { +type Arguments = [IEndpointRequest]; +type ReturnValue = IEndpointResponse; + +export default class RespondToEndpointRequest extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec(request: Arguments[0]): Promise { // Find the matching entrypoint const requestedUrl = join(request.hostname, request.path); const entrypoints = await this.storages.entrypoints.findManyByUrlMatcherHostname( diff --git a/core/src/usecases/UpdateApp.ts b/core/src/usecases/UpdateApp.ts index 56e1f512..b0462386 100644 --- a/core/src/usecases/UpdateApp.ts +++ b/core/src/usecases/UpdateApp.ts @@ -1,4 +1,4 @@ -import { AppNotFoundError } from "../common/errors"; +import { AppNotFoundError } from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { IApp } from "../entities/App"; import { @@ -7,13 +7,19 @@ import { } from "../entities/Configuration"; import { Operation } from "../entities/OperationLog"; -export default class UpdateApp extends Usecase { - async exec( - id: string, - patch: { - defaultConfiguration?: IConfiguration; - } - ): Promise { +type Arguments = [ + string, + { + defaultConfiguration?: IConfiguration; + } +]; +type ReturnValue = IApp; + +export default class UpdateApp extends Usecase { + protected async _exec( + id: Arguments[0], + patch: Arguments[1] + ): Promise { const existingApp = await this.storages.apps.findOne(id); // Ensure the app exists @@ -22,7 +28,7 @@ export default class UpdateApp extends Usecase { } // Auth check - await this.authorizer.ensureCanUpdateApp(existingApp.name); + this.authorizer.ensureCanUpdateApp(existingApp.name); // Validate defaultConfiguration if (patch.defaultConfiguration) { diff --git a/core/src/usecases/UpdateEntrypoint.ts b/core/src/usecases/UpdateEntrypoint.ts index 3a18328b..285668fa 100644 --- a/core/src/usecases/UpdateEntrypoint.ts +++ b/core/src/usecases/UpdateEntrypoint.ts @@ -1,4 +1,7 @@ -import { BundleNotFoundError, EntrypointNotFoundError } from "../common/errors"; +import { + BundleNotFoundError, + EntrypointNotFoundError +} from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { IConfiguration, @@ -7,15 +10,21 @@ import { import { IEntrypoint } from "../entities/Entrypoint"; import { Operation } from "../entities/OperationLog"; -export default class UpdateEntrypoint extends Usecase { - async exec( - id: string, - patch: { - bundleId?: string | null; - redirectTo?: string | null; - configuration?: IConfiguration | null; - } - ): Promise { +type Arguments = [ + string, + { + bundleId?: string | null; + redirectTo?: string | null; + configuration?: IConfiguration | null; + } +]; +type ReturnValue = IEntrypoint; + +export default class UpdateEntrypoint extends Usecase { + protected async _exec( + id: Arguments[0], + patch: Arguments[1] + ): Promise { const existingEntrypoint = await this.storages.entrypoints.findOne(id); // Ensure the entrypoint exists @@ -24,7 +33,7 @@ export default class UpdateEntrypoint extends Usecase { } // Auth check - await this.authorizer.ensureCanUpdateEntrypoint( + this.authorizer.ensureCanUpdateEntrypoint( existingEntrypoint.urlMatcher ); diff --git a/core/src/usecases/UpdateExternalCache.ts b/core/src/usecases/UpdateExternalCache.ts new file mode 100644 index 00000000..0f7581bd --- /dev/null +++ b/core/src/usecases/UpdateExternalCache.ts @@ -0,0 +1,91 @@ +import { + ConflictingExternalCacheError, + ExternalCacheNotFoundError +} from "../common/functionalErrors"; +import getSupportedExternalCacheTypes from "../common/getSupportedExternalCacheTypes"; +import Usecase from "../common/Usecase"; +import { + getMatchingExternalCacheType, + IExternalCache, + validateExternalCacheConfiguration, + validateExternalCacheDomain, + validateExternalCacheType +} from "../entities/ExternalCache"; +import { Operation } from "../entities/OperationLog"; + +type Arguments = [ + string, + { + domain?: string; + type?: string; + configuration?: IExternalCache["configuration"]; + } +]; +type ReturnValue = IExternalCache; + +export default class UpdateExternalCache extends Usecase< + Arguments, + ReturnValue +> { + protected async _exec( + id: Arguments[0], + patch: Arguments[1] + ): Promise { + const existingExternalCache = await this.storages.externalCaches.findOne( + id + ); + + // Ensure the externalCache exists + if (!existingExternalCache) { + throw new ExternalCacheNotFoundError(id); + } + + // Auth check + this.authorizer.ensureCanUpdateExternalCache(); + + // Validate patch + const supportedExternalCacheTypes = getSupportedExternalCacheTypes( + this.externalCacheServices + ); + const type = patch.type || existingExternalCache.type; + validateExternalCacheType(type, supportedExternalCacheTypes); + if (patch.domain) { + validateExternalCacheDomain(patch.domain); + } + if (patch.configuration) { + validateExternalCacheConfiguration( + patch.configuration, + getMatchingExternalCacheType(supportedExternalCacheTypes, type)! + ); + } + + // Ensure no externalCache with the same domain exists + if (patch.domain) { + const conflictingExternalCacheExists = await this.storages.externalCaches.oneExistsWithDomain( + patch.domain + ); + if (conflictingExternalCacheExists) { + throw new ConflictingExternalCacheError(patch.domain); + } + } + + // Update the externalCache + const updatedExternalCache = await this.storages.externalCaches.updateOne( + id, + { + domain: patch.domain, + type: patch.type, + configuration: patch.configuration, + updatedAt: new Date() + } + ); + + // Log the operation + await this.operationLogger.logOperation(Operation.UpdateExternalCache, { + oldExternalCache: existingExternalCache, + newExternalCache: updatedExternalCache + }); + + return updatedExternalCache; + } +} diff --git a/core/src/usecases/UpdateGroup.ts b/core/src/usecases/UpdateGroup.ts index 4fbc5567..74ad3f25 100644 --- a/core/src/usecases/UpdateGroup.ts +++ b/core/src/usecases/UpdateGroup.ts @@ -1,19 +1,28 @@ -import { ConflictingGroupError, GroupNotFoundError } from "../common/errors"; +import { + ConflictingGroupError, + GroupNotFoundError +} from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { IGroup } from "../entities/Group"; import { Operation } from "../entities/OperationLog"; import { validateRole } from "../entities/Role"; -export default class UpdateGroup extends Usecase { - async exec( - id: string, - patch: { - name?: string; - roles?: string[]; - } - ): Promise { +type Arguments = [ + string, + { + name?: string; + roles?: string[]; + } +]; +type ReturnValue = IGroup; + +export default class UpdateGroup extends Usecase { + protected async _exec( + id: Arguments[0], + patch: Arguments[1] + ): Promise { // Auth check - await this.authorizer.ensureCanUpdateGroup(); + this.authorizer.ensureCanUpdateGroup(); // Validate roles if (patch.roles) { diff --git a/core/src/usecases/UpdateUser.ts b/core/src/usecases/UpdateUser.ts index f19911fa..ed8e1b22 100644 --- a/core/src/usecases/UpdateUser.ts +++ b/core/src/usecases/UpdateUser.ts @@ -1,18 +1,27 @@ -import { SomeGroupNotFoundError, UserNotFoundError } from "../common/errors"; +import { + SomeGroupNotFoundError, + UserNotFoundError +} from "../common/functionalErrors"; import Usecase from "../common/Usecase"; import { Operation } from "../entities/OperationLog"; import { IUser } from "../entities/User"; -export default class UpdateUser extends Usecase { - async exec( - id: string, - patch: { - name?: string; - groupsIds?: string[]; - } - ): Promise { +type Arguments = [ + string, + { + name?: string; + groupsIds?: string[]; + } +]; +type ReturnValue = IUser; + +export default class UpdateUser extends Usecase { + protected async _exec( + id: Arguments[0], + patch: Arguments[1] + ): Promise { // Auth check - await this.authorizer.ensureCanUpdateUser(); + this.authorizer.ensureCanUpdateUser(); const existingUser = await this.storages.users.findOne(id); diff --git a/core/test/common/Usecase.ts b/core/test/common/Usecase.ts new file mode 100644 index 00000000..be4150ef --- /dev/null +++ b/core/test/common/Usecase.ts @@ -0,0 +1,137 @@ +import { expect } from "chai"; +import sinon from "sinon"; + +import { + AuthenticationRequiredError, + FunctionalError +} from "../../src/common/functionalErrors"; +import UnexpectedError from "../../src/common/UnexpectedError"; +import Usecase from "../../src/common/Usecase"; +import { getMockDependencies } from "../testUtils"; + +describe("common abstract class Usecase", () => { + describe("exec", () => { + const getUsecaseImpl = (_exec: () => Promise) => + class UsecaseImpl extends Usecase<[], void> { + protected _exec = _exec; + }; + + it("sets the usecase name in the log context", async () => { + const deps = getMockDependencies(); + + const UsecaseImpl = getUsecaseImpl(async () => undefined); + const usecaseImpl = new UsecaseImpl(deps); + await usecaseImpl.exec(); + + expect(deps.logger.addToContext).to.have.been.calledWith( + "usecase", + "UsecaseImpl" + ); + }); + + it("sets the user id in the log context", async () => { + const deps = getMockDependencies(); + + const UsecaseImpl = getUsecaseImpl(async () => undefined); + const usecaseImpl = new UsecaseImpl(deps); + await usecaseImpl.exec(); + + expect(deps.logger.addToContext).to.have.been.calledWith( + "userId", + "anonymous" + ); + }); + + it("logs the start of the execution", async () => { + const deps = getMockDependencies(); + + const UsecaseImpl = getUsecaseImpl(async () => undefined); + const usecaseImpl = new UsecaseImpl(deps); + await usecaseImpl.exec(); + + expect(deps.logger.info).to.have.been.calledWith( + "usecase execution started" + ); + }); + + describe("logs the end of the execution", () => { + it("case: success", async () => { + const deps = getMockDependencies(); + + const UsecaseImpl = getUsecaseImpl(async () => undefined); + const usecaseImpl = new UsecaseImpl(deps); + await usecaseImpl.exec(); + + expect(deps.logger.info).to.have.been.calledWith( + "usecase execution terminated successfully", + sinon.match({ execTimeMs: sinon.match.number }) + ); + }); + it("case: expected failure", async () => { + const deps = getMockDependencies(); + + const UsecaseImpl = getUsecaseImpl(async () => { + throw new AuthenticationRequiredError(); + }); + const usecaseImpl = new UsecaseImpl(deps); + try { + await usecaseImpl.exec(); + } catch { + // Ignore the thrown error + } + + expect(deps.logger.info).to.have.been.calledWith( + "usecase execution terminated with error", + sinon.match({ + execTimeMs: sinon.match.number, + error: sinon.match.instanceOf( + AuthenticationRequiredError + ) + }) + ); + }); + it("case: unexpected failure", async () => { + const deps = getMockDependencies(); + + const error = new Error("error message"); + const UsecaseImpl = getUsecaseImpl(async () => { + throw error; + }); + const usecaseImpl = new UsecaseImpl(deps); + try { + await usecaseImpl.exec(); + } catch { + // Ignore the thrown error + } + + expect(deps.logger.error).to.have.been.calledWith( + "usecase execution failed unexpectedly", + sinon.match({ + execTimeMs: sinon.match.number, + error: error + }) + ); + }); + }); + + it("re-throws FunctionalError-s thrown during the execution", async () => { + const UsecaseImpl = getUsecaseImpl(async () => { + throw new AuthenticationRequiredError(); + }); + const usecaseImpl = new UsecaseImpl(getMockDependencies()); + const execPromise = usecaseImpl.exec(); + + await expect(execPromise).to.be.rejectedWith(FunctionalError); + }); + + it("converts other execution errors to UnexpectedError-s", async () => { + const UsecaseImpl = getUsecaseImpl(async () => { + throw new Error(); + }); + const usecaseImpl = new UsecaseImpl(getMockDependencies()); + const execPromise = usecaseImpl.exec(); + + await expect(execPromise).to.be.rejectedWith(UnexpectedError); + }); + }); +}); diff --git a/core/test/entities/ExternalCache.ts b/core/test/entities/ExternalCache.ts new file mode 100644 index 00000000..43c7c79e --- /dev/null +++ b/core/test/entities/ExternalCache.ts @@ -0,0 +1,160 @@ +import { expect } from "chai"; + +import { + getMatchingExternalCacheType, + IExternalCacheType, + isExternalCacheConfigurationValid, + isExternalCacheDomainValid, + isExternalCacheTypeSupported +} from "../../src/entities/ExternalCache"; + +describe("ExternalCache entity util getMatchingExternalCacheType", () => { + const getExternalCacheTypes = (...names: string[]): IExternalCacheType[] => + names.map(name => ({ + name: name, + label: "label", + configurationFields: [] + })); + + it("returns the externalCacheType matching the passed in type, if one is found", () => { + const supportedExternalCacheTypes = getExternalCacheTypes( + "type0", + "type1", + "type2" + ); + expect( + getMatchingExternalCacheType(supportedExternalCacheTypes, "type1") + ).to.have.property("name", "type1"); + }); + + it("returns null if no externalCacheType is found matching the passed in type", () => { + const supportedExternalCacheTypes = getExternalCacheTypes( + "type0", + "type1", + "type2" + ); + expect( + getMatchingExternalCacheType(supportedExternalCacheTypes, "type3") + ).to.equal(null); + }); +}); + +describe("ExternalCache entity validator isExternalCacheTypeSupported", () => { + const getExternalCacheTypes = (...names: string[]): IExternalCacheType[] => + names.map(name => ({ + name: name, + label: "label", + configurationFields: [] + })); + + it("returns true when the passed-in type is found in the supported IExternalCacheType-s", () => { + const testCases: [string, IExternalCacheType[]][] = [ + ["type", getExternalCacheTypes("type")], + ["type0", getExternalCacheTypes("type0", "type1")], + ["type1", getExternalCacheTypes("type0", "type1")] + ]; + testCases.forEach(([type, supportedExternalCacheTypes]) => { + expect( + isExternalCacheTypeSupported(type, supportedExternalCacheTypes) + ).to.equal(true); + }); + }); + + it("returns false when the passed-in type is NOT found in the supported IExternalCacheType-s", () => { + const testCases: [string, IExternalCacheType[]][] = [ + ["type", getExternalCacheTypes("otherType")], + ["type2", getExternalCacheTypes("type0", "type1")] + ]; + testCases.forEach(([type, supportedExternalCacheTypes]) => { + expect( + isExternalCacheTypeSupported(type, supportedExternalCacheTypes) + ).to.equal(false); + }); + }); +}); + +describe("ExternalCache entity validator isExternalCacheDomainValid", () => { + describe("returns true when the passed-in domain is a valid FQDN", () => { + ["domain.com", "sub.domain.com"].forEach(domain => { + it(`case: ${domain}`, () => { + expect(isExternalCacheDomainValid(domain)).to.equal(true); + }); + }); + }); + + describe("returns false when the passed-in domain is not a valid FQDN", () => { + ["", "https://domain.com", "domain.com/"].forEach(domain => { + it(`case: ${domain}`, () => { + expect(isExternalCacheDomainValid(domain)).to.equal(false); + }); + }); + }); +}); + +describe("ExternalCache entity validator isExternalCacheConfigurationValid", () => { + const getExternalCacheType = ( + ...configurationFieldNames: string[] + ): IExternalCacheType => ({ + name: "name", + label: "label", + configurationFields: configurationFieldNames.map( + configurationFieldName => ({ + name: configurationFieldName, + label: "label", + placeholder: "placeholder" + }) + ) + }); + + describe("returns true when the passed-in configuration is valid", () => { + const testCases: [any, IExternalCacheType][] = [ + [{}, getExternalCacheType()], + [{ key: "value" }, getExternalCacheType("key")], + [ + { key: "value", otherKey: "otherValue" }, + getExternalCacheType("key", "otherKey") + ], + [ + { "key-with-special-chars": "value" }, + getExternalCacheType("key-with-special-chars") + ] + ]; + testCases.forEach(([configuration, externalCacheType]) => { + it(`case: ${JSON.stringify(configuration)}`, () => { + expect( + isExternalCacheConfigurationValid( + configuration, + externalCacheType + ) + ).to.equal(true); + }); + }); + }); + + describe("returns false when the passed-in configuration is not valid", () => { + const testCases: [any, IExternalCacheType][] = [ + ["not-an-object", getExternalCacheType()], + [0, getExternalCacheType()], + [true, getExternalCacheType()], + [null, getExternalCacheType()], + [[], getExternalCacheType()], + [{ key: 0 }, getExternalCacheType("key")], + [{ key: {} }, getExternalCacheType("key")], + [{ key: [] }, getExternalCacheType("key")], + [{ key: true }, getExternalCacheType("key")], + [{ key: null }, getExternalCacheType("key")], + [{ surplusKey: "surplusValue" }, getExternalCacheType()], + [{ key: "value" }, getExternalCacheType("key", "missingKey")] + ]; + testCases.forEach(([configuration, externalCacheType]) => { + it(`case: ${JSON.stringify(configuration)}`, () => { + expect( + isExternalCacheConfigurationValid( + configuration, + externalCacheType + ) + ).to.equal(false); + }); + }); + }); +}); diff --git a/core/test/services/Authenticator.ts b/core/test/services/Authenticator.ts index bd7fa5d7..7dbf79db 100644 --- a/core/test/services/Authenticator.ts +++ b/core/test/services/Authenticator.ts @@ -1,97 +1,172 @@ import { expect } from "chai"; +import { NoUserCorrespondingToIdpUserError } from "../../src/common/functionalErrors"; import IAuthenticationStrategy from "../../src/dependencies/IAuthenticationStrategy"; import Authenticator from "../../src/services/Authenticator"; +import { getMockDependencies } from "../testUtils"; describe("service Authenticator", () => { - describe("getIdpUser", () => { + describe("getUser", () => { const setup = () => Promise.resolve(); const idpUser0 = { id: "id0", idp: "idp0" }; const idpUser1 = { id: "id1", idp: "idp1" }; - it("returns null if the passed in auth token is null", async () => { - const authenticationStrategies: IAuthenticationStrategy[] = [ - { getIdpUserFromAuthToken: async () => idpUser0, setup } - ]; + it("returns null when auth is not enforced", async () => { + const deps = getMockDependencies(); const authenticator = new Authenticator( - authenticationStrategies, + deps.storages.users, + deps.authenticationStrategies, + false, null ); - const idpUser = await authenticator.getIdpUser(); - expect(idpUser).to.equal(null); + const user = await authenticator.getUser(); + expect(user).to.equal(null); }); - describe("returns the first non-null idp user returned by one of the authentications trategies", () => { - it("case: first authentication strategy returns a non-null idp user", async () => { - const authenticationStrategies: IAuthenticationStrategy[] = [ - { getIdpUserFromAuthToken: async () => idpUser0, setup }, - { getIdpUserFromAuthToken: async () => null, setup } - ]; + describe("returns null when no idp user is found", () => { + it("case: null authToken", async () => { + const deps = getMockDependencies(); const authenticator = new Authenticator( - authenticationStrategies, - "authToken" + deps.storages.users, + deps.authenticationStrategies, + true, + null ); - const idpUser = await authenticator.getIdpUser(); - expect(idpUser).to.equal(idpUser0); + const user = await authenticator.getUser(); + expect(user).to.equal(null); }); - - it("case: second authentication strategy returns a non-null idp user", async () => { - const authenticationStrategies: IAuthenticationStrategy[] = [ - { getIdpUserFromAuthToken: async () => null, setup }, - { getIdpUserFromAuthToken: async () => idpUser1, setup } - ]; - const authenticator = new Authenticator( - authenticationStrategies, - "authToken" - ); - const idpUser = await authenticator.getIdpUser(); - expect(idpUser).to.equal(idpUser1); + describe("case: all auth strategies return null", () => { + it("case: 0 auth strategies", async () => { + const authenticator = new Authenticator( + getMockDependencies().storages.users, + [], + true, + "authToken" + ); + const user = await authenticator.getUser(); + expect(user).to.equal(null); + }); + it("case: 1 auth strategy", async () => { + const authenticationStrategies: IAuthenticationStrategy[] = [ + { getIdpUserFromAuthToken: async () => null, setup } + ]; + const authenticator = new Authenticator( + getMockDependencies().storages.users, + authenticationStrategies, + true, + "authToken" + ); + const user = await authenticator.getUser(); + expect(user).to.equal(null); + }); + it("case: multiple auth strategies", async () => { + const authenticationStrategies: IAuthenticationStrategy[] = [ + { getIdpUserFromAuthToken: async () => null, setup }, + { getIdpUserFromAuthToken: async () => null, setup } + ]; + const authenticator = new Authenticator( + getMockDependencies().storages.users, + authenticationStrategies, + true, + "authToken" + ); + const user = await authenticator.getUser(); + expect(user).to.equal(null); + }); }); + }); - it("case: first and second authentication strategy return a non-null idp user", async () => { + it("throws NoUserCorrespondingToIdpUserError when no user corresponds to the idp user", async () => { + const deps = getMockDependencies(); + deps.storages.users.findOneWithRolesByIdpAndIdpId.resolves(null); + const authenticationStrategies: IAuthenticationStrategy[] = [ + { + getIdpUserFromAuthToken: async () => idpUser0, + setup + } + ]; + const authenticator = new Authenticator( + deps.storages.users, + authenticationStrategies, + true, + "authToken" + ); + const getUserPromise = authenticator.getUser(); + await expect(getUserPromise).to.be.rejectedWith( + NoUserCorrespondingToIdpUserError + ); + await expect(getUserPromise).to.be.rejectedWith( + "Access denied. To gain access, ask an admin to create a user with idp = idp0 and idpId = id0" + ); + }); + + describe("returns the user corresponding to the first idp user returned by an auth strategy", () => { + const deps = getMockDependencies(); + deps.storages.users.findOneWithRolesByIdpAndIdpId + .withArgs(idpUser0.idp, idpUser0.id) + .resolves({ id: idpUser0.id } as any) + .withArgs(idpUser1.idp, idpUser1.id) + .resolves({ id: idpUser1.id } as any); + + it("case: first auth strategy returns a non-null idp user", async () => { const authenticationStrategies: IAuthenticationStrategy[] = [ - { getIdpUserFromAuthToken: async () => idpUser0, setup }, - { getIdpUserFromAuthToken: async () => idpUser1, setup } + { + getIdpUserFromAuthToken: async () => idpUser0, + setup + }, + { + getIdpUserFromAuthToken: async () => null, + setup + } ]; const authenticator = new Authenticator( + deps.storages.users, authenticationStrategies, + true, "authToken" ); - const idpUser = await authenticator.getIdpUser(); - expect(idpUser).to.equal(idpUser0); + const user = await authenticator.getUser(); + expect(user).to.have.property("id", idpUser0.id); }); - }); - - describe("returns null if all authentication strategies return null", () => { - it("case: 0 authentication strategies", async () => { - const authenticator = new Authenticator([], "authToken"); - const idpUser = await authenticator.getIdpUser(); - expect(idpUser).to.equal(null); - }); - - it("case: 1 authentication strategy", async () => { + it("case: second auth strategy returns a non-null idp user", async () => { const authenticationStrategies: IAuthenticationStrategy[] = [ - { getIdpUserFromAuthToken: async () => null, setup } + { + getIdpUserFromAuthToken: async () => null, + setup + }, + { + getIdpUserFromAuthToken: async () => idpUser1, + setup + } ]; const authenticator = new Authenticator( + deps.storages.users, authenticationStrategies, + true, "authToken" ); - const idpUser = await authenticator.getIdpUser(); - expect(idpUser).to.equal(null); + const user = await authenticator.getUser(); + expect(user).to.have.property("id", idpUser1.id); }); - - it("case: multiple authentication strategies", async () => { + it("case: first AND second auth strategy returns a non-null idp user", async () => { const authenticationStrategies: IAuthenticationStrategy[] = [ - { getIdpUserFromAuthToken: async () => null, setup }, - { getIdpUserFromAuthToken: async () => null, setup } + { + getIdpUserFromAuthToken: async () => idpUser0, + setup + }, + { + getIdpUserFromAuthToken: async () => idpUser1, + setup + } ]; const authenticator = new Authenticator( + deps.storages.users, authenticationStrategies, + true, "authToken" ); - const idpUser = await authenticator.getIdpUser(); - expect(idpUser).to.equal(null); + const user = await authenticator.getUser(); + expect(user).to.have.property("id", idpUser0.id); }); }); }); diff --git a/core/test/services/Authorizer.ts b/core/test/services/Authorizer.ts index d1ad888b..7c2ccd2f 100644 --- a/core/test/services/Authorizer.ts +++ b/core/test/services/Authorizer.ts @@ -1,253 +1,166 @@ import { expect } from "chai"; -import sinon from "sinon"; import { AuthenticationRequiredError, - MissingRoleError, - NoUserCorrespondingToIdpUserError -} from "../../src/common/errors"; -import { IIdpUser } from "../../src/entities/User"; -import Authenticator from "../../src/services/Authenticator"; + MissingRoleError +} from "../../src/common/functionalErrors"; import Authorizer from "../../src/services/Authorizer"; -import { getMockDependencies } from "../testUtils"; - -function getMockAuthenticator(idpUser: IIdpUser | null): Authenticator { - return { - getIdpUser: sinon.stub().resolves(idpUser) - } as any; -} function getAuthorizerForUser(user: any) { - const deps = getMockDependencies(); - deps.storages.users.findOneWithRolesByIdpAndIdpId.resolves(user); - return new Authorizer( - deps.storages.users, - getMockAuthenticator({ id: "id", idp: "idp" }), - true - ); + const authorizer = new Authorizer(true); + authorizer._setUser(user); + return authorizer; } describe("service Authorizer", () => { // General describe("ensure* methods", () => { - it("don't throw anything when enforceAuth = false", async () => { - const deps = getMockDependencies(); - const authorizer = new Authorizer( - deps.storages.users, - getMockAuthenticator(null), - false - ); - await authorizer.ensureCanCreateApp(); - }); - it("throw AuthenticationRequiredError when the request is not authenticated", async () => { - const deps = getMockDependencies(); - const authorizer = new Authorizer( - deps.storages.users, - getMockAuthenticator(null), - true - ); - const ensurePromise = authorizer.ensureCanCreateApp(); - await expect(ensurePromise).to.be.rejectedWith( - AuthenticationRequiredError - ); - }); - it("throw UserNotFoundError when no user corresponds to the request idp user", async () => { - const deps = getMockDependencies(); - const authorizer = new Authorizer( - deps.storages.users, - getMockAuthenticator({ id: "id", idp: "idp" }), - true - ); - const ensurePromise = authorizer.ensureCanCreateApp(); - await expect(ensurePromise).to.be.rejectedWith( - NoUserCorrespondingToIdpUserError - ); + it("don't throw anything when enforceAuth = false", () => { + const authorizer = new Authorizer(false); + authorizer.ensureCanCreateApp(); }); - }); - - // Misc - describe("getCurrentUser", () => { - it("returns null when enforceAuth = false", async () => { - const deps = getMockDependencies(); - const authorizer = new Authorizer( - deps.storages.users, - getMockAuthenticator(null), - false - ); - const currentUser = await authorizer.getCurrentUser(); - expect(currentUser).to.equal(null); - }); - it("throws AuthenticationRequiredError when the request is not authenticated", async () => { - const deps = getMockDependencies(); - const authorizer = new Authorizer( - deps.storages.users, - getMockAuthenticator(null), - true - ); - const getCurrentUserPromise = authorizer.getCurrentUser(); - await expect(getCurrentUserPromise).to.be.rejectedWith( - AuthenticationRequiredError - ); - }); - it("throws UserNotFoundError when no user corresponds to the request idp user", async () => { - const deps = getMockDependencies(); - const authorizer = new Authorizer( - deps.storages.users, - getMockAuthenticator({ id: "id", idp: "idp" }), - true - ); - const getCurrentUserPromise = authorizer.getCurrentUser(); - await expect(getCurrentUserPromise).to.be.rejectedWith( - NoUserCorrespondingToIdpUserError - ); - }); - it("returns the request user", async () => { - const user = { id: "id", roles: [] }; - const authorizer = getAuthorizerForUser(user); - const currentUser = await authorizer.getCurrentUser(); - expect(currentUser).to.deep.equal(user); + it("throw AuthenticationRequiredError when the request is not authenticated", () => { + const authorizer = new Authorizer(true); + const troublemaker = () => authorizer.ensureCanCreateApp(); + expect(troublemaker).to.throw(AuthenticationRequiredError); }); }); // Apps describe("ensureCanCreateApp", () => { - it("throws MissingRoleError if the user is not allowed to create the app", async () => { + it("throws MissingRoleError if the user is not allowed to create the app", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - const ensurePromise = authorizer.ensureCanCreateApp(); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => authorizer.ensureCanCreateApp(); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to create the app", async () => { + it("doesn't throw if the user is allowed to create the app", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["root"] }); - await authorizer.ensureCanCreateApp(); + authorizer.ensureCanCreateApp(); }); }); describe("ensureCanUpdateApp", () => { - it("throws MissingRoleError if the user is not allowed to update the app", async () => { + it("throws MissingRoleError if the user is not allowed to update the app", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["app-manager:different-appName"] }); - const ensurePromise = authorizer.ensureCanUpdateApp("appName"); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => authorizer.ensureCanUpdateApp("appName"); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to update the app", async () => { + it("doesn't throw if the user is allowed to update the app", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["app-manager:appName"] }); - await authorizer.ensureCanUpdateApp("appName"); + authorizer.ensureCanUpdateApp("appName"); }); }); describe("ensureCanDeleteApp", () => { - it("throws MissingRoleError if the user is not allowed to delete the app", async () => { + it("throws MissingRoleError if the user is not allowed to delete the app", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["app-manager:different-appName"] }); - const ensurePromise = authorizer.ensureCanDeleteApp("appName"); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => authorizer.ensureCanDeleteApp("appName"); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to delete the app", async () => { + it("doesn't throw if the user is allowed to delete the app", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["app-manager:appName"] }); - await authorizer.ensureCanDeleteApp("appName"); + authorizer.ensureCanDeleteApp("appName"); }); }); describe("ensureCanGetApps", () => { - it("doesn't throw if the user is allowed to get apps", async () => { + it("doesn't throw if the user is allowed to get apps", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - await authorizer.ensureCanGetApps(); + authorizer.ensureCanGetApps(); }); }); // Bundles describe("ensureCanCreateBundle", () => { - it("throws MissingRoleError if the user is not allowed to create the bundle", async () => { + it("throws MissingRoleError if the user is not allowed to create the bundle", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["bundle-manager:different-bundleName"] }); - const ensurePromise = authorizer.ensureCanCreateBundle( - "bundleName" - ); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => + authorizer.ensureCanCreateBundle("bundleName"); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to create the bundle", async () => { + it("doesn't throw if the user is allowed to create the bundle", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["bundle-manager:bundleName"] }); - await authorizer.ensureCanCreateBundle("bundleName"); + authorizer.ensureCanCreateBundle("bundleName"); }); }); describe("ensureCanDeleteBundles", () => { - it("throws MissingRoleError if the user is not allowed to delete the bundles", async () => { + it("throws MissingRoleError if the user is not allowed to delete the bundles", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["bundle-manager:different-bundleName"] }); - const ensurePromise = authorizer.ensureCanDeleteBundles( - "bundleName" - ); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => + authorizer.ensureCanDeleteBundles("bundleName"); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to delete the bundles", async () => { + it("doesn't throw if the user is allowed to delete the bundles", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["bundle-manager:bundleName"] }); - await authorizer.ensureCanDeleteBundles("bundleName"); + authorizer.ensureCanDeleteBundles("bundleName"); }); }); describe("ensureCanGetBundles", () => { - it("doesn't throw if the user is allowed to get bundles", async () => { + it("doesn't throw if the user is allowed to get bundles", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - await authorizer.ensureCanGetBundles(); + authorizer.ensureCanGetBundles(); }); }); // Entrypoints describe("ensureCanCreateEntrypoint", () => { describe("throws MissingRoleError if the user is not allowed to create the entrypoint", () => { - it("case: missing entrypoint-manager role", async () => { + it("case: missing entrypoint-manager role", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["app-manager:appName"] }); - const ensurePromise = authorizer.ensureCanCreateEntrypoint( - "example.com/", - "appName" - ); - await expect(ensurePromise).to.be.rejectedWith( - MissingRoleError - ); - }); - it("case: missing app-manager role", async () => { + const troublemaker = () => + authorizer.ensureCanCreateEntrypoint( + "example.com/", + "appName" + ); + expect(troublemaker).to.throw(MissingRoleError); + }); + it("case: missing app-manager role", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["entrypoint-manager:example.com/"] }); - const ensurePromise = authorizer.ensureCanCreateEntrypoint( - "example.com/", - "appName" - ); - await expect(ensurePromise).to.be.rejectedWith( - MissingRoleError - ); + const troublemaker = () => + authorizer.ensureCanCreateEntrypoint( + "example.com/", + "appName" + ); + expect(troublemaker).to.throw(MissingRoleError); }); }); - it("doesn't throw if the user is allowed to create the entrypoint", async () => { + it("doesn't throw if the user is allowed to create the entrypoint", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [ @@ -255,186 +168,251 @@ describe("service Authorizer", () => { "app-manager:appName" ] }); - await authorizer.ensureCanCreateEntrypoint( - "example.com/", - "appName" - ); + authorizer.ensureCanCreateEntrypoint("example.com/", "appName"); }); }); describe("ensureCanUpdateEntrypoint", () => { - it("throws MissingRoleError if the user is not allowed to update the entrypoint", async () => { + it("throws MissingRoleError if the user is not allowed to update the entrypoint", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["entrypoint-manager:different-example.com/"] }); - const ensurePromise = authorizer.ensureCanUpdateEntrypoint( - "example.com/" - ); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => + authorizer.ensureCanUpdateEntrypoint("example.com/"); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to update the entrypoint", async () => { + it("doesn't throw if the user is allowed to update the entrypoint", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["entrypoint-manager:example.com/"] }); - await authorizer.ensureCanUpdateEntrypoint("example.com/"); + authorizer.ensureCanUpdateEntrypoint("example.com/"); }); }); describe("ensureCanDeleteEntrypoint", () => { - it("throws MissingRoleError if the user is not allowed to delete the entrypoint", async () => { + it("throws MissingRoleError if the user is not allowed to delete the entrypoint", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["entrypoint-manager:different-example.com/"] }); - const ensurePromise = authorizer.ensureCanDeleteEntrypoint( - "example.com/" - ); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => + authorizer.ensureCanDeleteEntrypoint("example.com/"); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to delete the entrypoint", async () => { + it("doesn't throw if the user is allowed to delete the entrypoint", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["entrypoint-manager:example.com/"] }); - await authorizer.ensureCanDeleteEntrypoint("example.com/"); + authorizer.ensureCanDeleteEntrypoint("example.com/"); }); }); describe("ensureCanGetEntrypoints", () => { - it("doesn't throw if the user is allowed to get entrypoints", async () => { + it("doesn't throw if the user is allowed to get entrypoints", () => { + const authorizer = getAuthorizerForUser({ + id: "id", + roles: [] + }); + authorizer.ensureCanGetEntrypoints(); + }); + }); + + // External caches + describe("ensureCanCreateExternalCache", () => { + it("throws MissingRoleError if the user is not allowed to create the external cache", () => { + const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); + const troublemaker = () => + authorizer.ensureCanCreateExternalCache(); + expect(troublemaker).to.throw(MissingRoleError); + }); + it("doesn't throw if the user is allowed to create the external cache", () => { + const authorizer = getAuthorizerForUser({ + id: "id", + roles: ["root"] + }); + authorizer.ensureCanCreateExternalCache(); + }); + }); + describe("ensureCanUpdateExternalCache", () => { + it("throws MissingRoleError if the user is not allowed to update the external cache", () => { + const authorizer = getAuthorizerForUser({ + id: "id", + roles: [] + }); + const troublemaker = () => + authorizer.ensureCanUpdateExternalCache(); + expect(troublemaker).to.throw(MissingRoleError); + }); + it("doesn't throw if the user is allowed to update the external cache", () => { + const authorizer = getAuthorizerForUser({ + id: "id", + roles: ["root"] + }); + authorizer.ensureCanUpdateExternalCache(); + }); + }); + describe("ensureCanDeleteExternalCache", () => { + it("throws MissingRoleError if the user is not allowed to delete the external cache", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - await authorizer.ensureCanGetEntrypoints(); + const troublemaker = () => + authorizer.ensureCanDeleteExternalCache(); + expect(troublemaker).to.throw(MissingRoleError); + }); + it("doesn't throw if the user is allowed to delete the external cache", () => { + const authorizer = getAuthorizerForUser({ + id: "id", + roles: ["root"] + }); + authorizer.ensureCanDeleteExternalCache(); + }); + }); + describe("ensureCanGetExternalCaches", () => { + it("throws MissingRoleError if the user is not allowed to get external caches", () => { + const authorizer = getAuthorizerForUser({ + id: "id", + roles: [] + }); + const troublemaker = () => authorizer.ensureCanGetExternalCaches(); + expect(troublemaker).to.throw(MissingRoleError); + }); + it("doesn't throw if the user is allowed to get external caches", () => { + const authorizer = getAuthorizerForUser({ + id: "id", + roles: ["root"] + }); + authorizer.ensureCanGetExternalCaches(); }); }); // Groups describe("ensureCanCreateGroup", () => { - it("throws MissingRoleError if the user is not allowed to create the group", async () => { + it("throws MissingRoleError if the user is not allowed to create the group", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - const ensurePromise = authorizer.ensureCanCreateGroup(); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => authorizer.ensureCanCreateGroup(); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to create the group", async () => { + it("doesn't throw if the user is allowed to create the group", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["root"] }); - await authorizer.ensureCanCreateGroup(); + authorizer.ensureCanCreateGroup(); }); }); describe("ensureCanUpdateGroup", () => { - it("throws MissingRoleError if the user is not allowed to update the group", async () => { + it("throws MissingRoleError if the user is not allowed to update the group", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - const ensurePromise = authorizer.ensureCanUpdateGroup(); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => authorizer.ensureCanUpdateGroup(); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to update the group", async () => { + it("doesn't throw if the user is allowed to update the group", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["root"] }); - await authorizer.ensureCanUpdateGroup(); + authorizer.ensureCanUpdateGroup(); }); }); describe("ensureCanDeleteGroup", () => { - it("throws MissingRoleError if the user is not allowed to delete the group", async () => { + it("throws MissingRoleError if the user is not allowed to delete the group", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - const ensurePromise = authorizer.ensureCanDeleteGroup(); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => authorizer.ensureCanDeleteGroup(); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to delete the group", async () => { + it("doesn't throw if the user is allowed to delete the group", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["root"] }); - await authorizer.ensureCanDeleteGroup(); + authorizer.ensureCanDeleteGroup(); }); }); describe("ensureCanGetGroups", () => { - it("doesn't throw if the user is allowed to get groups", async () => { + it("doesn't throw if the user is allowed to get groups", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - await authorizer.ensureCanGetGroups(); + authorizer.ensureCanGetGroups(); }); }); // Operation logs describe("ensureCanGetOperationLogs", () => { - it("doesn't throw if the user is allowed to get operation logs", async () => { + it("doesn't throw if the user is allowed to get operation logs", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - await authorizer.ensureCanGetOperationLogs(); + authorizer.ensureCanGetOperationLogs(); }); }); // Users describe("ensureCanCreateUser", () => { - it("throws MissingRoleError if the user is not allowed to create the user", async () => { + it("throws MissingRoleError if the user is not allowed to create the user", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - const ensurePromise = authorizer.ensureCanCreateUser(); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => authorizer.ensureCanCreateUser(); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to create the user", async () => { + it("doesn't throw if the user is allowed to create the user", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["root"] }); - await authorizer.ensureCanCreateUser(); + authorizer.ensureCanCreateUser(); }); }); describe("ensureCanUpdateUser", () => { - it("throws MissingRoleError if the user is not allowed to update the user", async () => { + it("throws MissingRoleError if the user is not allowed to update the user", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - const ensurePromise = authorizer.ensureCanUpdateUser(); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => authorizer.ensureCanUpdateUser(); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to update the user", async () => { + it("doesn't throw if the user is allowed to update the user", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["root"] }); - await authorizer.ensureCanUpdateUser(); + authorizer.ensureCanUpdateUser(); }); }); describe("ensureCanDeleteUser", () => { - it("throws MissingRoleError if the user is not allowed to delete the user", async () => { + it("throws MissingRoleError if the user is not allowed to delete the user", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - const ensurePromise = authorizer.ensureCanDeleteUser(); - await expect(ensurePromise).to.be.rejectedWith(MissingRoleError); + const troublemaker = () => authorizer.ensureCanDeleteUser(); + expect(troublemaker).to.throw(MissingRoleError); }); - it("doesn't throw if the user is allowed to delete the user", async () => { + it("doesn't throw if the user is allowed to delete the user", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: ["root"] }); - await authorizer.ensureCanDeleteUser(); + authorizer.ensureCanDeleteUser(); }); }); describe("ensureCanGetUsers", () => { - it("doesn't throw if the user is allowed to get users", async () => { + it("doesn't throw if the user is allowed to get users", () => { const authorizer = getAuthorizerForUser({ id: "id", roles: [] }); - await authorizer.ensureCanGetUsers(); + authorizer.ensureCanGetUsers(); }); }); }); diff --git a/core/test/testUtils.ts b/core/test/testUtils.ts index 43d3a4d2..eac80872 100644 --- a/core/test/testUtils.ts +++ b/core/test/testUtils.ts @@ -5,12 +5,16 @@ import IArchiver from "../src/dependencies/IArchiver"; import IAuthenticationStrategy from "../src/dependencies/IAuthenticationStrategy"; import IBundlesStorage from "../src/dependencies/IBundlesStorage"; import IEntrypointsStorage from "../src/dependencies/IEntrypointsStorage"; +import IExternalCacheService from "../src/dependencies/IExternalCacheService"; +import IExternalCachesStorage from "../src/dependencies/IExternalCachesStorage"; import IGroupsStorage from "../src/dependencies/IGroupsStorage"; +import ILogger from "../src/dependencies/ILogger"; import IOperationLogsStorage from "../src/dependencies/IOperationLogsStorage"; import IRequestContext from "../src/dependencies/IRequestContext"; import IStorages from "../src/dependencies/IStorages"; import IUsecaseConfig from "../src/dependencies/IUsecaseConfig"; import IUsersStorage from "../src/dependencies/IUsersStorage"; +import { IExternalCacheType } from "../src/entities/ExternalCache"; // Dependencies mock interface IMockDependencies { @@ -20,12 +24,25 @@ interface IMockDependencies { ReturnType >; }; + logger: { + [method in keyof ILogger]: SinonStub< + Parameters, + ReturnType + >; + }; authenticationStrategies: { [method in keyof IAuthenticationStrategy]: SinonStub< Parameters, ReturnType >; }[]; + externalCacheServices: { + externalCacheType: IExternalCacheType; + purge: SinonStub< + Parameters, + ReturnType + >; + }[]; config: IUsecaseConfig; requestContext: IRequestContext; storages: { @@ -47,6 +64,12 @@ interface IMockDependencies { ReturnType >; }; + externalCaches: { + [method in keyof IExternalCachesStorage]: SinonStub< + Parameters, + ReturnType + >; + }; groups: { [method in keyof IGroupsStorage]: SinonStub< Parameters, @@ -77,7 +100,13 @@ export function getMockDependencies(): IMockDependencies { extractFiles: sinon.stub(), makeArchive: sinon.stub() }, + logger: { + addToContext: sinon.stub(), + info: sinon.stub(), + error: sinon.stub() + }, authenticationStrategies: [], + externalCacheServices: [], config: { enforceAuth: false }, @@ -119,6 +148,15 @@ export function getMockDependencies(): IMockDependencies { updateOne: sinon.stub(), deleteOne: sinon.stub() }, + externalCaches: { + findOne: sinon.stub(), + findOneByDomain: sinon.stub(), + findMany: sinon.stub(), + oneExistsWithDomain: sinon.stub(), + createOne: sinon.stub(), + updateOne: sinon.stub(), + deleteOne: sinon.stub() + }, groups: { findOne: sinon.stub(), findOneByName: sinon.stub(), diff --git a/core/test/usecases/CreateApp.ts b/core/test/usecases/CreateApp.ts index e49efb86..ee173ac1 100644 --- a/core/test/usecases/CreateApp.ts +++ b/core/test/usecases/CreateApp.ts @@ -5,7 +5,7 @@ import { AppNameNotValidError, ConfigurationNotValidError, ConflictingAppError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import CreateApp from "../../src/usecases/CreateApp"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/CreateBundle.ts b/core/test/usecases/CreateBundle.ts index 6e0765a4..dd329bfd 100644 --- a/core/test/usecases/CreateBundle.ts +++ b/core/test/usecases/CreateBundle.ts @@ -4,7 +4,7 @@ import sinon from "sinon"; import { BundleFallbackAssetNotFoundError, BundleNameOrTagNotValidError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import CreateBundle from "../../src/usecases/CreateBundle"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/CreateEntrypoint.ts b/core/test/usecases/CreateEntrypoint.ts index 81e4c495..0965fcf1 100644 --- a/core/test/usecases/CreateEntrypoint.ts +++ b/core/test/usecases/CreateEntrypoint.ts @@ -7,7 +7,7 @@ import { ConfigurationNotValidError, ConflictingEntrypointError, EntrypointUrlMatcherNotValidError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import CreateEntrypoint from "../../src/usecases/CreateEntrypoint"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/CreateExternalCache.ts b/core/test/usecases/CreateExternalCache.ts new file mode 100644 index 00000000..0fc18b45 --- /dev/null +++ b/core/test/usecases/CreateExternalCache.ts @@ -0,0 +1,153 @@ +import { expect } from "chai"; +import sinon, { SinonStub } from "sinon"; + +import { + ConflictingExternalCacheError, + ExternalCacheConfigurationNotValidError, + ExternalCacheDomainNotValidError, + ExternalCacheTypeNotSupportedError +} from "../../src/common/functionalErrors"; +import IExternalCacheService from "../../src/dependencies/IExternalCacheService"; +import { IExternalCacheType } from "../../src/entities/ExternalCache"; +import { Operation } from "../../src/entities/OperationLog"; +import CreateExternalCache from "../../src/usecases/CreateExternalCache"; +import { getMockDependencies } from "../testUtils"; + +describe("usecase CreateExternalCache", () => { + const getMockExternalCacheService = (): { + externalCacheType: IExternalCacheType; + purge: SinonStub< + Parameters, + ReturnType + >; + } => ({ + externalCacheType: { + name: "type", + label: "label", + configurationFields: [] + }, + purge: sinon.stub() + }); + + it("throws ExternalCacheTypeNotSupportedError if the type is not supported", async () => { + const createExternalCache = new CreateExternalCache( + getMockDependencies() + ); + const createExternalCachePromise = createExternalCache.exec({ + domain: "domain.com", + type: "type", + configuration: {} + }); + await expect(createExternalCachePromise).to.be.rejectedWith( + ExternalCacheTypeNotSupportedError + ); + await expect(createExternalCachePromise).to.be.rejectedWith( + "type is not a supported external cache type" + ); + }); + + it("throws ExternalCacheDomainNotValidError if the name is not valid", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + const createExternalCache = new CreateExternalCache(deps); + const createExternalCachePromise = createExternalCache.exec({ + domain: "https://domain.com", + type: "type", + configuration: {} + }); + await expect(createExternalCachePromise).to.be.rejectedWith( + ExternalCacheDomainNotValidError + ); + await expect(createExternalCachePromise).to.be.rejectedWith( + "https://domain.com is not a valid domain name" + ); + }); + + it("throws ExternalCacheConfigurationNotValidError if the configuration is not valid", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + const createExternalCache = new CreateExternalCache(deps); + const createExternalCachePromise = createExternalCache.exec({ + domain: "domain.com", + type: "type", + configuration: "not-valid-configuration" as any + }); + await expect(createExternalCachePromise).to.be.rejectedWith( + ExternalCacheConfigurationNotValidError + ); + await expect(createExternalCachePromise).to.be.rejectedWith( + "Invalid external cache configuration object" + ); + }); + + it("throws ConflictingExternalCacheError if an externalCache with the same domain exists", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + deps.storages.externalCaches.oneExistsWithDomain.resolves(true); + const createExternalCache = new CreateExternalCache(deps); + const createExternalCachePromise = createExternalCache.exec({ + domain: "domain.com", + type: "type", + configuration: {} + }); + await expect(createExternalCachePromise).to.be.rejectedWith( + ConflictingExternalCacheError + ); + await expect(createExternalCachePromise).to.be.rejectedWith( + "An external cache with domain = domain.com already exists" + ); + }); + + it("creates an externalCache", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + const createExternalCache = new CreateExternalCache(deps); + await createExternalCache.exec({ + domain: "domain.com", + type: "type", + configuration: {} + }); + expect( + deps.storages.externalCaches.createOne + ).to.have.been.calledOnceWith({ + id: sinon.match.string, + domain: "domain.com", + type: "type", + configuration: {}, + createdAt: sinon.match.date, + updatedAt: sinon.match.date + }); + }); + + it("logs the create externalCache operation", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + const createExternalCache = new CreateExternalCache(deps); + await createExternalCache.exec({ + domain: "domain.com", + type: "type", + configuration: {} + }); + expect( + deps.storages.operationLogs.createOne + ).to.have.been.calledOnceWith( + sinon.match.has("operation", Operation.CreateExternalCache) + ); + }); + + it("returns the created externalCache", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + const mockCreatedExternalCache = {} as any; + deps.storages.externalCaches.createOne.resolves( + mockCreatedExternalCache + ); + const createExternalCache = new CreateExternalCache(deps); + const createdExternalCache = await createExternalCache.exec({ + domain: "domain.com", + type: "type", + configuration: {} + }); + expect(createdExternalCache).to.equal(mockCreatedExternalCache); + }); +}); diff --git a/core/test/usecases/CreateGroup.ts b/core/test/usecases/CreateGroup.ts index c5c9365a..3926ed94 100644 --- a/core/test/usecases/CreateGroup.ts +++ b/core/test/usecases/CreateGroup.ts @@ -4,7 +4,7 @@ import sinon from "sinon"; import { ConflictingGroupError, RoleNotValidError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import CreateGroup from "../../src/usecases/CreateGroup"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/CreateRootUserAndGroup.ts b/core/test/usecases/CreateRootUserAndGroup.ts index a819bf20..343f3bda 100644 --- a/core/test/usecases/CreateRootUserAndGroup.ts +++ b/core/test/usecases/CreateRootUserAndGroup.ts @@ -12,12 +12,12 @@ import { getMockDependencies } from "../testUtils"; describe("usecase CreateRootUserAndGroup", () => { it("creates the root group if it doesn't exist", async () => { - const { storages } = getMockDependencies(); - storages.groups.findOneByName.resolves(null); - storages.groups.createOne.resolves({ id: "groupId" } as any); - const createRootUserAndGroup = new CreateRootUserAndGroup(storages); + const deps = getMockDependencies(); + deps.storages.groups.findOneByName.resolves(null); + deps.storages.groups.createOne.resolves({ id: "groupId" } as any); + const createRootUserAndGroup = new CreateRootUserAndGroup(deps); await createRootUserAndGroup.exec("idp"); - expect(storages.groups.createOne).to.have.been.calledOnceWith({ + expect(deps.storages.groups.createOne).to.have.been.calledOnceWith({ id: sinon.match.string, name: ROOT_GROUP_NAME, roles: [RoleName.Root], @@ -27,20 +27,20 @@ describe("usecase CreateRootUserAndGroup", () => { }); it("doesn't create the root group if it exists", async () => { - const { storages } = getMockDependencies(); - storages.groups.findOneByName.resolves({ id: "groupId" } as any); - const createRootUserAndGroup = new CreateRootUserAndGroup(storages); + const deps = getMockDependencies(); + deps.storages.groups.findOneByName.resolves({ id: "groupId" } as any); + const createRootUserAndGroup = new CreateRootUserAndGroup(deps); await createRootUserAndGroup.exec("idp"); - expect(storages.groups.createOne).to.have.callCount(0); + expect(deps.storages.groups.createOne).to.have.callCount(0); }); it("creates the root user if it doesn't exist", async () => { - const { storages } = getMockDependencies(); - storages.groups.findOneByName.resolves({ id: "groupId" } as any); - storages.users.oneExistsWithIdpAndIdpId.resolves(false); - const createRootUserAndGroup = new CreateRootUserAndGroup(storages); + const deps = getMockDependencies(); + deps.storages.groups.findOneByName.resolves({ id: "groupId" } as any); + deps.storages.users.oneExistsWithIdpAndIdpId.resolves(false); + const createRootUserAndGroup = new CreateRootUserAndGroup(deps); await createRootUserAndGroup.exec("idp"); - expect(storages.users.createOne).to.have.been.calledOnceWith({ + expect(deps.storages.users.createOne).to.have.been.calledOnceWith({ id: sinon.match.string, idp: "idp", idpId: ROOT_USER_IDP_ID, @@ -53,11 +53,11 @@ describe("usecase CreateRootUserAndGroup", () => { }); it("doesn't create the root user if it exists", async () => { - const { storages } = getMockDependencies(); - storages.groups.findOneByName.resolves({ id: "groupId" } as any); - storages.users.oneExistsWithIdpAndIdpId.resolves(true); - const createRootUserAndGroup = new CreateRootUserAndGroup(storages); + const deps = getMockDependencies(); + deps.storages.groups.findOneByName.resolves({ id: "groupId" } as any); + deps.storages.users.oneExistsWithIdpAndIdpId.resolves(true); + const createRootUserAndGroup = new CreateRootUserAndGroup(deps); await createRootUserAndGroup.exec("idp"); - expect(storages.users.createOne).to.have.callCount(0); + expect(deps.storages.users.createOne).to.have.callCount(0); }); }); diff --git a/core/test/usecases/CreateUser.ts b/core/test/usecases/CreateUser.ts index 6c5a4552..f547c563 100644 --- a/core/test/usecases/CreateUser.ts +++ b/core/test/usecases/CreateUser.ts @@ -4,7 +4,7 @@ import sinon from "sinon"; import { ConflictingUserError, SomeGroupNotFoundError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import { UserType } from "../../src/entities/User"; import CreateUser from "../../src/usecases/CreateUser"; diff --git a/core/test/usecases/DeleteApp.ts b/core/test/usecases/DeleteApp.ts index b4bc244a..78a935a0 100644 --- a/core/test/usecases/DeleteApp.ts +++ b/core/test/usecases/DeleteApp.ts @@ -4,7 +4,7 @@ import sinon from "sinon"; import { AppHasEntrypointsError, AppNotFoundError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import DeleteApp from "../../src/usecases/DeleteApp"; import { getMockDependencies } from "../testUtils"; @@ -21,7 +21,7 @@ describe("usecase DeleteApp", () => { it("throws AppHasEntrypointsError if the app has linked entrypoints", async () => { const deps = getMockDependencies(); - deps.storages.apps.findOne.resolves({ appId: "appId" } as any); + deps.storages.apps.findOne.resolves({} as any); deps.storages.entrypoints.anyExistsWithAppId.resolves(true); const deleteApp = new DeleteApp(deps); const deleteAppPromise = deleteApp.exec("appId"); @@ -35,7 +35,7 @@ describe("usecase DeleteApp", () => { it("deletes the app", async () => { const deps = getMockDependencies(); - deps.storages.apps.findOne.resolves({ appId: "appId" } as any); + deps.storages.apps.findOne.resolves({} as any); const deleteApp = new DeleteApp(deps); await deleteApp.exec("appId"); expect(deps.storages.apps.deleteOne).to.have.been.calledOnceWith( @@ -45,7 +45,7 @@ describe("usecase DeleteApp", () => { it("logs the delete app operation", async () => { const deps = getMockDependencies(); - deps.storages.apps.findOne.resolves({ appId: "appId" } as any); + deps.storages.apps.findOne.resolves({} as any); const deleteApp = new DeleteApp(deps); await deleteApp.exec("appId"); expect( diff --git a/core/test/usecases/DeleteBundlesByNameAndTag.ts b/core/test/usecases/DeleteBundlesByNameAndTag.ts index 4cca1ef8..13b63024 100644 --- a/core/test/usecases/DeleteBundlesByNameAndTag.ts +++ b/core/test/usecases/DeleteBundlesByNameAndTag.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import sinon from "sinon"; -import { BundlesInUseError } from "../../src/common/errors"; +import { BundlesInUseError } from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import DeleteBundlesByNameAndTag from "../../src/usecases/DeleteBundlesByNameAndTag"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/DeleteEntrypoint.ts b/core/test/usecases/DeleteEntrypoint.ts index 2714b80a..3cffea1e 100644 --- a/core/test/usecases/DeleteEntrypoint.ts +++ b/core/test/usecases/DeleteEntrypoint.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import sinon from "sinon"; -import { EntrypointNotFoundError } from "../../src/common/errors"; +import { EntrypointNotFoundError } from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import DeleteEntrypoint from "../../src/usecases/DeleteEntrypoint"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/DeleteExternalCache.ts b/core/test/usecases/DeleteExternalCache.ts new file mode 100644 index 00000000..b3843a4a --- /dev/null +++ b/core/test/usecases/DeleteExternalCache.ts @@ -0,0 +1,46 @@ +import { expect } from "chai"; +import sinon from "sinon"; + +import { ExternalCacheNotFoundError } from "../../src/common/functionalErrors"; +import { Operation } from "../../src/entities/OperationLog"; +import DeleteExternalCache from "../../src/usecases/DeleteExternalCache"; +import { getMockDependencies } from "../testUtils"; + +describe("usecase DeleteExternalCache", () => { + it("throws ExternalCacheNotFoundError if no externalCache with the specified id exists", async () => { + const deleteExternalCache = new DeleteExternalCache( + getMockDependencies() + ); + const deleteExternalCachePromise = deleteExternalCache.exec( + "externalCacheId" + ); + await expect(deleteExternalCachePromise).to.be.rejectedWith( + ExternalCacheNotFoundError + ); + await expect(deleteExternalCachePromise).to.be.rejectedWith( + "No external cache found with id = externalCacheId" + ); + }); + + it("deletes the externalCache", async () => { + const deps = getMockDependencies(); + deps.storages.externalCaches.findOne.resolves({} as any); + const deleteExternalCache = new DeleteExternalCache(deps); + await deleteExternalCache.exec("externalCacheId"); + expect( + deps.storages.externalCaches.deleteOne + ).to.have.been.calledOnceWith("externalCacheId"); + }); + + it("logs the delete externalCache operation", async () => { + const deps = getMockDependencies(); + deps.storages.externalCaches.findOne.resolves({} as any); + const deleteExternalCache = new DeleteExternalCache(deps); + await deleteExternalCache.exec("externalCacheId"); + expect( + deps.storages.operationLogs.createOne + ).to.have.been.calledOnceWith( + sinon.match.has("operation", Operation.DeleteExternalCache) + ); + }); +}); diff --git a/core/test/usecases/DeleteGroup.ts b/core/test/usecases/DeleteGroup.ts index 3910c49c..22ac28f9 100644 --- a/core/test/usecases/DeleteGroup.ts +++ b/core/test/usecases/DeleteGroup.ts @@ -4,7 +4,7 @@ import sinon from "sinon"; import { GroupHasUsersError, GroupNotFoundError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import DeleteGroup from "../../src/usecases/DeleteGroup"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/DeleteUser.ts b/core/test/usecases/DeleteUser.ts index 1d3e9ff9..c4be1e96 100644 --- a/core/test/usecases/DeleteUser.ts +++ b/core/test/usecases/DeleteUser.ts @@ -1,7 +1,7 @@ import { expect } from "chai"; import sinon from "sinon"; -import { UserNotFoundError } from "../../src/common/errors"; +import { UserNotFoundError } from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import DeleteUser from "../../src/usecases/DeleteUser"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/DeployBundle.ts b/core/test/usecases/DeployBundle.ts index 3deae457..7e8d8f3f 100644 --- a/core/test/usecases/DeployBundle.ts +++ b/core/test/usecases/DeployBundle.ts @@ -5,7 +5,7 @@ import { BundleNameTagCombinationNotValidError, BundleNotFoundError, EntrypointMismatchedAppIdError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import DeployBundle from "../../src/usecases/DeployBundle"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/GetApp.ts b/core/test/usecases/GetApp.ts index d7e8f0f1..ccf52065 100644 --- a/core/test/usecases/GetApp.ts +++ b/core/test/usecases/GetApp.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; -import { AppNotFoundError } from "../../src/common/errors"; +import { AppNotFoundError } from "../../src/common/functionalErrors"; import GetApp from "../../src/usecases/GetApp"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/GetBundle.ts b/core/test/usecases/GetBundle.ts index e0ad157d..116059c3 100644 --- a/core/test/usecases/GetBundle.ts +++ b/core/test/usecases/GetBundle.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; -import { BundleNotFoundError } from "../../src/common/errors"; +import { BundleNotFoundError } from "../../src/common/functionalErrors"; import GetBundle from "../../src/usecases/GetBundle"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/GetCurrentUser.ts b/core/test/usecases/GetCurrentUser.ts index b7f249dc..796fb887 100644 --- a/core/test/usecases/GetCurrentUser.ts +++ b/core/test/usecases/GetCurrentUser.ts @@ -15,7 +15,9 @@ describe("usecase GetCurrentUser", () => { it("case: enforceAuth = true", async () => { const deps = getMockDependencies(); deps.config.enforceAuth = true; - deps.requestContext = { authToken: "authToken" }; + deps.requestContext = { + authToken: "authToken" + }; const mockUser = {} as any; deps.authenticationStrategies.push({ getIdpUserFromAuthToken: sinon diff --git a/core/test/usecases/GetEntrypoint.ts b/core/test/usecases/GetEntrypoint.ts index 077296c1..eb2963a5 100644 --- a/core/test/usecases/GetEntrypoint.ts +++ b/core/test/usecases/GetEntrypoint.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; -import { EntrypointNotFoundError } from "../../src/common/errors"; +import { EntrypointNotFoundError } from "../../src/common/functionalErrors"; import GetEntrypoint from "../../src/usecases/GetEntrypoint"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/GetEntrypointsByAppId.ts b/core/test/usecases/GetEntrypointsByAppId.ts index d892279b..917a82f1 100644 --- a/core/test/usecases/GetEntrypointsByAppId.ts +++ b/core/test/usecases/GetEntrypointsByAppId.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; -import { AppNotFoundError } from "../../src/common/errors"; +import { AppNotFoundError } from "../../src/common/functionalErrors"; import GetEntrypointsByAppId from "../../src/usecases/GetEntrypointsByAppId"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/GetExternalCache.ts b/core/test/usecases/GetExternalCache.ts new file mode 100644 index 00000000..ba280df1 --- /dev/null +++ b/core/test/usecases/GetExternalCache.ts @@ -0,0 +1,29 @@ +import { expect } from "chai"; + +import { ExternalCacheNotFoundError } from "../../src/common/functionalErrors"; +import GetExternalCache from "../../src/usecases/GetExternalCache"; +import { getMockDependencies } from "../testUtils"; + +describe("usecase GetExternalCache", () => { + it("throws ExternalCacheNotFoundError if an externalCache with the specified id doesn't exist", async () => { + const getExternalCache = new GetExternalCache(getMockDependencies()); + const getExternalCachePromise = getExternalCache.exec( + "externalCacheId" + ); + await expect(getExternalCachePromise).to.be.rejectedWith( + ExternalCacheNotFoundError + ); + await expect(getExternalCachePromise).to.be.rejectedWith( + "No external cache found with id = externalCacheId" + ); + }); + + it("returns the externalCache with the specified id", async () => { + const deps = getMockDependencies(); + const mockExternalCache = {} as any; + deps.storages.externalCaches.findOne.resolves(mockExternalCache); + const getExternalCache = new GetExternalCache(deps); + const externalCache = await getExternalCache.exec("externalCacheId"); + expect(externalCache).to.equal(mockExternalCache); + }); +}); diff --git a/core/test/usecases/GetExternalCaches.ts b/core/test/usecases/GetExternalCaches.ts new file mode 100644 index 00000000..89585dd5 --- /dev/null +++ b/core/test/usecases/GetExternalCaches.ts @@ -0,0 +1,15 @@ +import { expect } from "chai"; + +import GetExternalCaches from "../../src/usecases/GetExternalCaches"; +import { getMockDependencies } from "../testUtils"; + +describe("usecase GetExternalCaches", () => { + it("returns the externalCaches found with the specified search filters (none for now)", async () => { + const deps = getMockDependencies(); + const mockExternalCaches = [] as any; + deps.storages.externalCaches.findMany.resolves(mockExternalCaches); + const getExternalCaches = new GetExternalCaches(deps); + const externalCaches = await getExternalCaches.exec(); + expect(externalCaches).to.equal(mockExternalCaches); + }); +}); diff --git a/core/test/usecases/GetGroup.ts b/core/test/usecases/GetGroup.ts index 16d23335..e0a7c5e1 100644 --- a/core/test/usecases/GetGroup.ts +++ b/core/test/usecases/GetGroup.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; -import { GroupNotFoundError } from "../../src/common/errors"; +import { GroupNotFoundError } from "../../src/common/functionalErrors"; import GetGroup from "../../src/usecases/GetGroup"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/GetSupportedExternalCacheTypes.ts b/core/test/usecases/GetSupportedExternalCacheTypes.ts new file mode 100644 index 00000000..218a193d --- /dev/null +++ b/core/test/usecases/GetSupportedExternalCacheTypes.ts @@ -0,0 +1,27 @@ +import { expect } from "chai"; +import sinon from "sinon"; + +import GetSupportedExternalCacheTypes from "../../src/usecases/GetSupportedExternalCacheTypes"; +import { getMockDependencies } from "../testUtils"; + +describe("usecase GetSupportedExternalCacheTypes", () => { + it("returns a list of the supported external cache types", async () => { + const mockExternalCacheTypes = [ + { name: "type0", label: "label0", configurationFields: [] }, + { name: "type1", label: "label1", configurationFields: [] }, + { name: "type2", label: "label2", configurationFields: [] } + ]; + const deps = getMockDependencies(); + deps.externalCacheServices = mockExternalCacheTypes.map( + externalCacheType => ({ + externalCacheType: externalCacheType, + purge: sinon.stub() + }) + ); + const getSupportedExternalCacheTypes = new GetSupportedExternalCacheTypes( + deps + ); + const externalCacheTypes = await getSupportedExternalCacheTypes.exec(); + expect(externalCacheTypes).to.deep.equal(mockExternalCacheTypes); + }); +}); diff --git a/core/test/usecases/GetUser.ts b/core/test/usecases/GetUser.ts index e4bdcdc0..1fea4d44 100644 --- a/core/test/usecases/GetUser.ts +++ b/core/test/usecases/GetUser.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; -import { UserNotFoundError } from "../../src/common/errors"; +import { UserNotFoundError } from "../../src/common/functionalErrors"; import GetUser from "../../src/usecases/GetUser"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/PurgeExternalCache.ts b/core/test/usecases/PurgeExternalCache.ts new file mode 100644 index 00000000..287d3426 --- /dev/null +++ b/core/test/usecases/PurgeExternalCache.ts @@ -0,0 +1,122 @@ +import { expect } from "chai"; +import sinon, { SinonStub } from "sinon"; + +import { + ExternalCacheNotFoundError, + ExternalCacheTypeNotSupportedError +} from "../../src/common/functionalErrors"; +import IExternalCacheService from "../../src/dependencies/IExternalCacheService"; +import { IExternalCacheType } from "../../src/entities/ExternalCache"; +import { Operation } from "../../src/entities/OperationLog"; +import PurgeExternalCache from "../../src/usecases/PurgeExternalCache"; +import { getMockDependencies } from "../testUtils"; + +describe("usecase PurgeExternalCache", () => { + const getMockExternalCacheService = ( + type: string + ): { + externalCacheType: IExternalCacheType; + purge: SinonStub< + Parameters, + ReturnType + >; + } => ({ + externalCacheType: { + name: type, + label: "label", + configurationFields: [] + }, + purge: sinon.stub() + }); + + it("throws ExternalCacheNotFoundError if no externalCache with the specified id exists", async () => { + const purgeExternalCache = new PurgeExternalCache( + getMockDependencies() + ); + const purgeExternalCachePromise = purgeExternalCache.exec( + "externalCacheId", + [] + ); + await expect(purgeExternalCachePromise).to.be.rejectedWith( + ExternalCacheNotFoundError + ); + await expect(purgeExternalCachePromise).to.be.rejectedWith( + "No external cache found with id = externalCacheId" + ); + }); + + describe("throws ExternalCacheTypeNotSupportedError if no external cache service matches the specified external cache's type", async () => { + it("case: no external cache services", async () => { + const deps = getMockDependencies(); + deps.storages.externalCaches.findOne.resolves({ + type: "type" + } as any); + const purgeExternalCache = new PurgeExternalCache(deps); + const purgeExternalCachePromise = purgeExternalCache.exec( + "externalCacheId", + [] + ); + await expect(purgeExternalCachePromise).to.be.rejectedWith( + ExternalCacheTypeNotSupportedError + ); + await expect(purgeExternalCachePromise).to.be.rejectedWith( + "type is not a supported external cache type" + ); + }); + it("case: multiple external cache services", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push( + getMockExternalCacheService("type0") + ); + deps.externalCacheServices.push( + getMockExternalCacheService("type1") + ); + deps.storages.externalCaches.findOne.resolves({ + type: "type2" + } as any); + const purgeExternalCache = new PurgeExternalCache(deps); + const purgeExternalCachePromise = purgeExternalCache.exec( + "externalCacheId", + [] + ); + await expect(purgeExternalCachePromise).to.be.rejectedWith( + ExternalCacheTypeNotSupportedError + ); + await expect(purgeExternalCachePromise).to.be.rejectedWith( + "type2 is not a supported external cache type" + ); + }); + }); + + it("purges the cache via the externalCacheService", async () => { + const deps = getMockDependencies(); + const externalCacheService = getMockExternalCacheService("type"); + deps.externalCacheServices.push(externalCacheService); + deps.storages.externalCaches.findOne.resolves({ + type: "type", + configuration: {} + } as any); + const purgeExternalCache = new PurgeExternalCache(deps); + await purgeExternalCache.exec("externalCacheId", ["/*"]); + await expect(externalCacheService.purge).to.have.been.calledOnceWith( + ["/*"], + {} + ); + }); + + it("logs the purge externalCache operation", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService("type")); + deps.storages.externalCaches.findOne.resolves({ + type: "type", + configuration: {} + } as any); + const purgeExternalCache = new PurgeExternalCache(deps); + await purgeExternalCache.exec("externalCacheId", ["/*"]); + expect( + deps.storages.operationLogs.createOne + ).to.have.been.calledOnceWith( + sinon.match.has("operation", Operation.PurgeExternalCache) + ); + }); +}); diff --git a/core/test/usecases/RespondToEndpointRequest/routing.ts b/core/test/usecases/RespondToEndpointRequest/routing.ts index 4102921f..ab9c7d37 100644 --- a/core/test/usecases/RespondToEndpointRequest/routing.ts +++ b/core/test/usecases/RespondToEndpointRequest/routing.ts @@ -1,7 +1,7 @@ import { NoBundleOrRedirectToError, NoMatchingEntrypointError -} from "../../../src/common/errors"; +} from "../../../src/common/functionalErrors"; import { htmlWith, test } from "./testUtils"; describe("usecase RespondToEndpointRequest (routing)", () => { diff --git a/core/test/usecases/UpdateApp.ts b/core/test/usecases/UpdateApp.ts index 633c4d4e..4f6b1e28 100644 --- a/core/test/usecases/UpdateApp.ts +++ b/core/test/usecases/UpdateApp.ts @@ -4,7 +4,7 @@ import sinon from "sinon"; import { AppNotFoundError, ConfigurationNotValidError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import UpdateApp from "../../src/usecases/UpdateApp"; import { getMockDependencies } from "../testUtils"; @@ -21,7 +21,7 @@ describe("usecase UpdateApp", () => { it("throws ConfigurationNotValidError if the defaultConfiguration is not valid", async () => { const deps = getMockDependencies(); - deps.storages.apps.findOne.resolves({ appId: "appId" } as any); + deps.storages.apps.findOne.resolves({} as any); const updateApp = new UpdateApp(deps); const updateAppPromise = updateApp.exec("appId", { defaultConfiguration: "not-valid-configuration" as any @@ -36,7 +36,7 @@ describe("usecase UpdateApp", () => { it("updates the app", async () => { const deps = getMockDependencies(); - deps.storages.apps.findOne.resolves({ appId: "appId" } as any); + deps.storages.apps.findOne.resolves({} as any); const updateApp = new UpdateApp(deps); await updateApp.exec("appId", { defaultConfiguration: {} }); expect(deps.storages.apps.updateOne).to.have.been.calledOnceWith( @@ -50,7 +50,7 @@ describe("usecase UpdateApp", () => { it("logs the update app operation", async () => { const deps = getMockDependencies(); - deps.storages.apps.findOne.resolves({ appId: "appId" } as any); + deps.storages.apps.findOne.resolves({} as any); const updateApp = new UpdateApp(deps); await updateApp.exec("appId", {}); expect( @@ -63,7 +63,7 @@ describe("usecase UpdateApp", () => { it("returns the updated app", async () => { const deps = getMockDependencies(); const mockUpdatedApp = {} as any; - deps.storages.apps.findOne.resolves({ appId: "appId" } as any); + deps.storages.apps.findOne.resolves({} as any); deps.storages.apps.updateOne.resolves(mockUpdatedApp); const updateApp = new UpdateApp(deps); const updatedApp = await updateApp.exec("appId", {}); diff --git a/core/test/usecases/UpdateEntrypoint.ts b/core/test/usecases/UpdateEntrypoint.ts index 1ef4c090..ebbe5df1 100644 --- a/core/test/usecases/UpdateEntrypoint.ts +++ b/core/test/usecases/UpdateEntrypoint.ts @@ -5,7 +5,7 @@ import { BundleNotFoundError, ConfigurationNotValidError, EntrypointNotFoundError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import UpdateEntrypoint from "../../src/usecases/UpdateEntrypoint"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/UpdateExternalCache.ts b/core/test/usecases/UpdateExternalCache.ts new file mode 100644 index 00000000..668f9bdf --- /dev/null +++ b/core/test/usecases/UpdateExternalCache.ts @@ -0,0 +1,164 @@ +import { expect } from "chai"; +import sinon, { SinonStub } from "sinon"; + +import { + ConflictingExternalCacheError, + ExternalCacheConfigurationNotValidError, + ExternalCacheDomainNotValidError, + ExternalCacheNotFoundError, + ExternalCacheTypeNotSupportedError +} from "../../src/common/functionalErrors"; +import IExternalCacheService from "../../src/dependencies/IExternalCacheService"; +import { IExternalCacheType } from "../../src/entities/ExternalCache"; +import { Operation } from "../../src/entities/OperationLog"; +import UpdateExternalCache from "../../src/usecases/UpdateExternalCache"; +import { getMockDependencies } from "../testUtils"; + +describe("usecase UpdateExternalCache", () => { + const getMockExternalCacheService = (): { + externalCacheType: IExternalCacheType; + purge: SinonStub< + Parameters, + ReturnType + >; + } => ({ + externalCacheType: { + name: "type", + label: "label", + configurationFields: [] + }, + purge: sinon.stub() + }); + + it("throws ExternalCacheNotFoundError if no externalCache with the specified id exists", async () => { + const updateExternalCache = new UpdateExternalCache( + getMockDependencies() + ); + const updateExternalCachePromise = updateExternalCache.exec( + "externalCacheId", + {} + ); + await expect(updateExternalCachePromise).to.be.rejectedWith( + ExternalCacheNotFoundError + ); + await expect(updateExternalCachePromise).to.be.rejectedWith( + "No external cache found with id = externalCacheId" + ); + }); + + it("throws ExternalCacheTypeNotSupportedError if the type is not supported", async () => { + const deps = getMockDependencies(); + deps.storages.externalCaches.findOne.resolves({} as any); + const updateExternalCache = new UpdateExternalCache(deps); + const updateExternalCachePromise = updateExternalCache.exec("id", { + domain: "domain.com", + type: "type", + configuration: {} + }); + await expect(updateExternalCachePromise).to.be.rejectedWith( + ExternalCacheTypeNotSupportedError + ); + await expect(updateExternalCachePromise).to.be.rejectedWith( + "type is not a supported external cache type" + ); + }); + + it("throws ExternalCacheDomainNotValidError if the domain is not valid", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + deps.storages.externalCaches.findOne.resolves({ type: "type" } as any); + const updateExternalCache = new UpdateExternalCache(deps); + const updateExternalCachePromise = updateExternalCache.exec( + "externalCacheId", + { domain: "https://domain.com" } + ); + await expect(updateExternalCachePromise).to.be.rejectedWith( + ExternalCacheDomainNotValidError + ); + await expect(updateExternalCachePromise).to.be.rejectedWith( + "https://domain.com is not a valid domain name" + ); + }); + + it("throws ExternalCacheConfigurationNotValidError if the configuration is not valid", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + deps.storages.externalCaches.findOne.resolves({ type: "type" } as any); + const updateExternalCache = new UpdateExternalCache(deps); + const updateExternalCachePromise = updateExternalCache.exec( + "externalCacheId", + { configuration: "not-valid-configuration" as any } + ); + await expect(updateExternalCachePromise).to.be.rejectedWith( + ExternalCacheConfigurationNotValidError + ); + await expect(updateExternalCachePromise).to.be.rejectedWith( + "Invalid external cache configuration object" + ); + }); + + it("throws ConflictingExternalCacheError if an externalCache with the same domain exists", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + deps.storages.externalCaches.findOne.resolves({ type: "type" } as any); + deps.storages.externalCaches.oneExistsWithDomain.resolves(true); + const updateExternalCache = new UpdateExternalCache(deps); + const updateExternalCachePromise = updateExternalCache.exec( + "externalCacheId", + { domain: "domain.com" } + ); + await expect(updateExternalCachePromise).to.be.rejectedWith( + ConflictingExternalCacheError + ); + await expect(updateExternalCachePromise).to.be.rejectedWith( + "An external cache with domain = domain.com already exists" + ); + }); + + it("updates the externalCache", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + deps.storages.externalCaches.findOne.resolves({ type: "type" } as any); + const updateExternalCache = new UpdateExternalCache(deps); + await updateExternalCache.exec("externalCacheId", { + domain: "domain.com" + }); + expect( + deps.storages.externalCaches.updateOne + ).to.have.been.calledOnceWith("externalCacheId", { + domain: "domain.com", + type: undefined, + configuration: undefined, + updatedAt: sinon.match.date + }); + }); + + it("logs the update externalCache operation", async () => { + const deps = getMockDependencies(); + deps.externalCacheServices.push(getMockExternalCacheService()); + deps.storages.externalCaches.findOne.resolves({ type: "type" } as any); + const updateExternalCache = new UpdateExternalCache(deps); + await updateExternalCache.exec("externalCacheId", {}); + expect( + deps.storages.operationLogs.createOne + ).to.have.been.calledOnceWith( + sinon.match.has("operation", Operation.UpdateExternalCache) + ); + }); + + it("returns the updated externalCache", async () => { + const deps = getMockDependencies(); + const mockUpdatedExternalCache = {} as any; + deps.externalCacheServices.push(getMockExternalCacheService()); + deps.storages.externalCaches.findOne.resolves({ type: "type" } as any); + deps.storages.externalCaches.updateOne.resolves( + mockUpdatedExternalCache + ); + const updateExternalCache = new UpdateExternalCache(deps); + const updatedExternalCache = await updateExternalCache.exec( + "externalCacheId", + {} + ); + expect(updatedExternalCache).to.equal(mockUpdatedExternalCache); + }); +}); diff --git a/core/test/usecases/UpdateUser.ts b/core/test/usecases/UpdateUser.ts index 5a2fa62a..d741d094 100644 --- a/core/test/usecases/UpdateUser.ts +++ b/core/test/usecases/UpdateUser.ts @@ -4,7 +4,7 @@ import sinon from "sinon"; import { SomeGroupNotFoundError, UserNotFoundError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import UpdateUser from "../../src/usecases/UpdateUser"; import { getMockDependencies } from "../testUtils"; diff --git a/core/test/usecases/UpdategGroup.ts b/core/test/usecases/UpdategGroup.ts index df21f691..c30540cd 100644 --- a/core/test/usecases/UpdategGroup.ts +++ b/core/test/usecases/UpdategGroup.ts @@ -5,7 +5,7 @@ import { ConflictingGroupError, GroupNotFoundError, RoleNotValidError -} from "../../src/common/errors"; +} from "../../src/common/functionalErrors"; import { Operation } from "../../src/entities/OperationLog"; import UpdateGroup from "../../src/usecases/UpdateGroup"; import { getMockDependencies } from "../testUtils"; diff --git a/http-adapters/package.json b/http-adapters/package.json index 1945791a..60bc07d0 100644 --- a/http-adapters/package.json +++ b/http-adapters/package.json @@ -36,12 +36,14 @@ "devDependencies": { "@types/chai": "^4.2.7", "@types/express": "^4.17.2", + "@types/lodash": "^4.14.149", "@types/mocha": "^5.2.7", "@types/sinon": "^7.5.1", "@types/sinon-chai": "^3.2.3", "@types/supertest": "^2.0.8", "chai": "^4.2.0", "express": "^4.17.1", + "lodash": "^4.17.15", "mocha": "^7.0.0", "prettier": "^1.19.1", "rimraf": "^3.0.0", diff --git a/http-adapters/src/IBaseRequest.ts b/http-adapters/src/IBaseRequest.ts index 7ee70d13..cb5ed824 100644 --- a/http-adapters/src/IBaseRequest.ts +++ b/http-adapters/src/IBaseRequest.ts @@ -1,8 +1,10 @@ +import { ILogger } from "@staticdeploy/core"; import { Request } from "express"; import IUsecasesByName from "./IUsecasesByName"; export default interface IBaseRequest extends Request { + log: ILogger; makeUsecase: ( name: Name ) => InstanceType; diff --git a/http-adapters/src/IUsecasesByName.ts b/http-adapters/src/IUsecasesByName.ts index ac10a9ae..dd8c6f35 100644 --- a/http-adapters/src/IUsecasesByName.ts +++ b/http-adapters/src/IUsecasesByName.ts @@ -5,11 +5,13 @@ export default interface IUsecasesByName { createApp: typeof sd.CreateApp; createBundle: typeof sd.CreateBundle; createEntrypoint: typeof sd.CreateEntrypoint; + createExternalCache: typeof sd.CreateExternalCache; createGroup: typeof sd.CreateGroup; createUser: typeof sd.CreateUser; deleteApp: typeof sd.DeleteApp; deleteBundlesByNameAndTag: typeof sd.DeleteBundlesByNameAndTag; deleteEntrypoint: typeof sd.DeleteEntrypoint; + deleteExternalCache: typeof sd.DeleteExternalCache; deleteGroup: typeof sd.DeleteGroup; deleteUser: typeof sd.DeleteUser; deployBundle: typeof sd.DeployBundle; @@ -23,14 +25,19 @@ export default interface IUsecasesByName { getCurrentUser: typeof sd.GetCurrentUser; getEntrypoint: typeof sd.GetEntrypoint; getEntrypointsByAppId: typeof sd.GetEntrypointsByAppId; + getExternalCache: typeof sd.GetExternalCache; + getExternalCaches: typeof sd.GetExternalCaches; + getSupportedExternalCacheTypes: typeof sd.GetSupportedExternalCacheTypes; getGroup: typeof sd.GetGroup; getGroups: typeof sd.GetGroups; getOperationLogs: typeof sd.GetOperationLogs; getUser: typeof sd.GetUser; getUsers: typeof sd.GetUsers; + purgeExternalCache: typeof sd.PurgeExternalCache; respondToEndpointRequest: typeof sd.RespondToEndpointRequest; updateApp: typeof sd.UpdateApp; updateEntrypoint: typeof sd.UpdateEntrypoint; + updateExternalCache: typeof sd.UpdateExternalCache; updateGroup: typeof sd.UpdateGroup; updateUser: typeof sd.UpdateUser; } diff --git a/http-adapters/src/managementApiAdapter/handleUsecaseErrors.ts b/http-adapters/src/managementApiAdapter/handleUsecaseErrors.ts index cec720c5..3563ca93 100644 --- a/http-adapters/src/managementApiAdapter/handleUsecaseErrors.ts +++ b/http-adapters/src/managementApiAdapter/handleUsecaseErrors.ts @@ -1,8 +1,10 @@ import * as sd from "@staticdeploy/core"; import { IConvRoute } from "convexpress"; +import IBaseRequest from "../IBaseRequest"; + type ErrorStatusMapping = [any, number]; -const errorStatusMappings: ErrorStatusMapping[] = [ +export const errorStatusMappings: ErrorStatusMapping[] = [ // Auth errors [sd.AuthenticationRequiredError, 401], [sd.NoUserCorrespondingToIdpUserError, 403], @@ -26,6 +28,12 @@ const errorStatusMappings: ErrorStatusMapping[] = [ [sd.EntrypointNotFoundError, 404], [sd.ConflictingEntrypointError, 409], [sd.EntrypointMismatchedAppIdError, 400], + // ExternalCache errors + [sd.ExternalCacheTypeNotSupportedError, 400], + [sd.ExternalCacheDomainNotValidError, 400], + [sd.ExternalCacheConfigurationNotValidError, 400], + [sd.ConflictingExternalCacheError, 409], + [sd.ExternalCacheNotFoundError, 404], // Endpoint response errors [sd.NoMatchingEntrypointError, 404], [sd.NoBundleOrRedirectToError, 404], @@ -34,12 +42,12 @@ const errorStatusMappings: ErrorStatusMapping[] = [ [sd.SomeGroupNotFoundError, 404], [sd.ConflictingGroupError, 409], [sd.GroupHasUsersError, 409], + [sd.RoleNotValidError, 400], // User errors [sd.UserNotFoundError, 404], [sd.ConflictingUserError, 409], - // Storage errors - [sd.GenericStoragesError, 500], - [sd.StoragesInconsistencyError, 500] + // Unexpected error + [sd.UnexpectedError, 500] ]; function findMatchingMapping(err: any): ErrorStatusMapping | null { return ( @@ -50,18 +58,26 @@ function findMatchingMapping(err: any): ErrorStatusMapping | null { export default ( handler: IConvRoute["handler"] -): IConvRoute["handler"] => async (req, res) => { +): IConvRoute["handler"] => async (req: IBaseRequest, res) => { try { await (handler as any)(req, res); - } catch (err) { - const matchingMapping = findMatchingMapping(err); + } catch (error) { + const matchingMapping = findMatchingMapping(error); if (!matchingMapping) { - throw err; + req.log.error("unhandled request error", { + error: error + }); + res.status(500).send({ + name: "UnhandledRequestError", + message: + "An unexpected error occurred while performing the operation" + }); + } else { + const [ErrorClass, statusCode] = matchingMapping; + res.status(statusCode).send({ + name: ErrorClass.name, + message: error.message + }); } - const [ErrorClass, statusCode] = matchingMapping; - res.status(statusCode).send({ - name: ErrorClass.name, - message: err.message - }); } }; diff --git a/http-adapters/src/managementApiAdapter/routes/CreateExternalCache.ts b/http-adapters/src/managementApiAdapter/routes/CreateExternalCache.ts new file mode 100644 index 00000000..f3cafe63 --- /dev/null +++ b/http-adapters/src/managementApiAdapter/routes/CreateExternalCache.ts @@ -0,0 +1,49 @@ +import { IExternalCache } from "@staticdeploy/core"; + +import IBaseRequest from "../../IBaseRequest"; +import convroute from "../convroute"; + +interface IRequest extends IBaseRequest { + body: { + domain: IExternalCache["domain"]; + type: IExternalCache["type"]; + configuration: IExternalCache["configuration"]; + }; +} + +const bodySchema = { + type: "object", + properties: { + domain: { type: "string" }, + type: { type: "string" }, + configuration: { type: "object" } + }, + required: ["domain", "type", "configuration"], + additionalProperties: false +}; + +export default convroute({ + path: "/externalCaches", + method: "post", + description: "Create a new external cache", + tags: ["externalCaches"], + parameters: [ + { + name: "externalCache", + in: "body", + required: true, + schema: bodySchema + } + ], + responses: { + "201": { + description: "External cache created, returns the external cache" + }, + "409": { description: "External cache with same domain already exists" } + }, + handler: async (req: IRequest, res) => { + const createExternalCache = req.makeUsecase("createExternalCache"); + const createdExternalCache = await createExternalCache.exec(req.body); + res.status(201).send(createdExternalCache); + } +}); diff --git a/http-adapters/src/managementApiAdapter/routes/DeleteExternalCache.ts b/http-adapters/src/managementApiAdapter/routes/DeleteExternalCache.ts new file mode 100644 index 00000000..44aee70e --- /dev/null +++ b/http-adapters/src/managementApiAdapter/routes/DeleteExternalCache.ts @@ -0,0 +1,32 @@ +import IBaseRequest from "../../IBaseRequest"; +import convroute from "../convroute"; + +interface IRequest extends IBaseRequest { + params: { + externalCacheId: string; + }; +} + +export default convroute({ + path: "/externalCaches/:externalCacheId", + method: "delete", + description: "Delete external cache", + tags: ["externalCaches"], + parameters: [ + { + name: "externalCacheId", + in: "path", + required: true, + type: "string" + } + ], + responses: { + "204": { description: "External cache deleted, returns nothing" }, + "404": { description: "External cache not found" } + }, + handler: async (req: IRequest, res) => { + const deleteExternalCache = req.makeUsecase("deleteExternalCache"); + await deleteExternalCache.exec(req.params.externalCacheId); + res.status(204).send(); + } +}); diff --git a/http-adapters/src/managementApiAdapter/routes/GetExternalCache.ts b/http-adapters/src/managementApiAdapter/routes/GetExternalCache.ts new file mode 100644 index 00000000..e59d9453 --- /dev/null +++ b/http-adapters/src/managementApiAdapter/routes/GetExternalCache.ts @@ -0,0 +1,34 @@ +import IBaseRequest from "../../IBaseRequest"; +import convroute from "../convroute"; + +interface IRequest extends IBaseRequest { + params: { + externalCacheId: string; + }; +} + +export default convroute({ + path: "/externalCaches/:externalCacheId", + method: "get", + description: "Get external cache", + tags: ["externalCaches"], + parameters: [ + { + name: "externalCacheId", + in: "path", + required: true, + type: "string" + } + ], + responses: { + "200": { description: "Returns the external cache" }, + "404": { description: "External cache not found" } + }, + handler: async (req: IRequest, res) => { + const getExternalCache = req.makeUsecase("getExternalCache"); + const externalCache = await getExternalCache.exec( + req.params.externalCacheId + ); + res.status(200).send(externalCache); + } +}); diff --git a/http-adapters/src/managementApiAdapter/routes/GetExternalCaches.ts b/http-adapters/src/managementApiAdapter/routes/GetExternalCaches.ts new file mode 100644 index 00000000..c24558d9 --- /dev/null +++ b/http-adapters/src/managementApiAdapter/routes/GetExternalCaches.ts @@ -0,0 +1,16 @@ +import convroute from "../convroute"; + +export default convroute({ + path: "/externalCaches", + method: "get", + description: "Get all external caches", + tags: ["externalCaches"], + responses: { + "200": { description: "Returns an array of all external caches" } + }, + handler: async (req, res) => { + const getExternalCaches = req.makeUsecase("getExternalCaches"); + const externalCaches = await getExternalCaches.exec(); + res.status(200).send(externalCaches); + } +}); diff --git a/http-adapters/src/managementApiAdapter/routes/GetSupportedExternalCacheTypes.ts b/http-adapters/src/managementApiAdapter/routes/GetSupportedExternalCacheTypes.ts new file mode 100644 index 00000000..70d9d46a --- /dev/null +++ b/http-adapters/src/managementApiAdapter/routes/GetSupportedExternalCacheTypes.ts @@ -0,0 +1,21 @@ +import convroute from "../convroute"; + +export default convroute({ + path: "/supportedExternalCacheTypes", + method: "get", + description: "Get all supported external cache type", + tags: ["externalCaches"], + responses: { + "200": { + description: + "Returns an array of all supported external cache types" + } + }, + handler: async (req, res) => { + const getSupportedExternalCacheTypes = req.makeUsecase( + "getSupportedExternalCacheTypes" + ); + const externalCacheTypes = await getSupportedExternalCacheTypes.exec(); + res.status(200).send(externalCacheTypes); + } +}); diff --git a/http-adapters/src/managementApiAdapter/routes/PurgeExternalCache.ts b/http-adapters/src/managementApiAdapter/routes/PurgeExternalCache.ts new file mode 100644 index 00000000..1bf758c7 --- /dev/null +++ b/http-adapters/src/managementApiAdapter/routes/PurgeExternalCache.ts @@ -0,0 +1,47 @@ +import IBaseRequest from "../../IBaseRequest"; +import convroute from "../convroute"; + +interface IRequest extends IBaseRequest { + params: { + externalCacheId: string; + }; + body: string[]; +} + +const bodySchema = { + type: "array", + items: { type: "string" }, + minItems: 1 +}; + +export default convroute({ + path: "/externalCaches/:externalCacheId", + method: "purge", + description: "Purges the external cache", + tags: ["externalCaches"], + parameters: [ + { + name: "externalCacheId", + in: "path", + required: true, + type: "string" + }, + { + name: "paths", + in: "body", + required: true, + schema: bodySchema + } + ], + responses: { + "202": { + description: "External cache purge command issued" + }, + "404": { description: "External cache not found" } + }, + handler: async (req: IRequest, res) => { + const purgeExternalCache = req.makeUsecase("purgeExternalCache"); + await purgeExternalCache.exec(req.params.externalCacheId, req.body); + res.status(202).send(); + } +}); diff --git a/http-adapters/src/managementApiAdapter/routes/UpdateExternalCache.ts b/http-adapters/src/managementApiAdapter/routes/UpdateExternalCache.ts new file mode 100644 index 00000000..a8e7e86e --- /dev/null +++ b/http-adapters/src/managementApiAdapter/routes/UpdateExternalCache.ts @@ -0,0 +1,61 @@ +import { IExternalCache } from "@staticdeploy/core"; + +import IBaseRequest from "../../IBaseRequest"; +import convroute from "../convroute"; + +interface IRequest extends IBaseRequest { + params: { + externalCacheId: string; + }; + body: { + domain?: IExternalCache["domain"]; + type?: IExternalCache["type"]; + configuration?: IExternalCache["configuration"]; + }; +} + +const bodySchema = { + type: "object", + properties: { + domain: { type: "string" }, + type: { type: "string" }, + configuration: { type: "object" } + }, + additionalProperties: false +}; + +export default convroute({ + path: "/externalCaches/:externalCacheId", + method: "patch", + description: "Update external cache", + tags: ["externalCaches"], + parameters: [ + { + name: "externalCacheId", + in: "path", + required: true, + type: "string" + }, + { + name: "patch", + in: "body", + required: true, + schema: bodySchema + } + ], + responses: { + "200": { + description: "External cache updated, returns the external cache" + }, + "404": { description: "External cache not found" }, + "409": { description: "External cache with same domain already exists" } + }, + handler: async (req: IRequest, res) => { + const updateExternalCache = req.makeUsecase("updateExternalCache"); + const updatedExternalCache = await updateExternalCache.exec( + req.params.externalCacheId, + req.body + ); + res.status(200).send(updatedExternalCache); + } +}); diff --git a/http-adapters/src/staticServerAdapter/staticRoute.ts b/http-adapters/src/staticServerAdapter/staticRoute.ts index 419a152e..4e1ce0ab 100644 --- a/http-adapters/src/staticServerAdapter/staticRoute.ts +++ b/http-adapters/src/staticServerAdapter/staticRoute.ts @@ -31,8 +31,11 @@ export default function staticRoute(options: { res.status(response.statusCode) .set(response.headers) .send(response.body); - } catch (err) { - next(err); + } catch (error) { + req.log.error("unhandled request error", { + error: error + }); + next(error); } }; } diff --git a/http-adapters/test/managementApiAdapter/handleUsecaseErrors.ts b/http-adapters/test/managementApiAdapter/handleUsecaseErrors.ts new file mode 100644 index 00000000..98ba6d19 --- /dev/null +++ b/http-adapters/test/managementApiAdapter/handleUsecaseErrors.ts @@ -0,0 +1,91 @@ +import * as sd from "@staticdeploy/core"; +import { expect } from "chai"; +import express from "express"; +import { filter } from "lodash"; +import sinon from "sinon"; +import request from "supertest"; + +import handleUsecaseErrors, { + errorStatusMappings +} from "../../src/managementApiAdapter/handleUsecaseErrors"; + +const getServerThrowing = (error: any, logError?: any) => + express() + .use((req, _res, next) => { + (req as any).log = { error: logError ?? (() => undefined) }; + next(); + }) + .use( + handleUsecaseErrors(() => { + throw error; + }) + ); + +describe("handleUsecaseErrors", () => { + it("handles FunctionalError-s", () => { + return request(getServerThrowing(new sd.AuthenticationRequiredError())) + .get("/") + .expect(401) + .expect({ + name: "AuthenticationRequiredError", + message: + "This operation requires the request to be authenticated" + }); + }); + + it("handles ALL FunctionalError-s", () => { + filter( + sd, + (value: any) => + Object.getPrototypeOf(value).name === "FunctionalError" + ).forEach((FunctionalErrorClass: any) => { + const mapping = errorStatusMappings.find( + ([ErrorClass]) => ErrorClass === FunctionalErrorClass + ); + if (!mapping) { + throw new Error( + `Mapping not found for error ${FunctionalErrorClass.name}` + ); + } + }); + }); + + it("handles UnexpectedError", () => { + return request(getServerThrowing(new sd.UnexpectedError())) + .get("/") + .expect(500) + .expect({ + name: "UnexpectedError", + message: + "An unexpected error occurred while performing the operation" + }); + }); + + it("doesn't handle other errors", () => { + return request(getServerThrowing(new Error())) + .get("/") + .expect(500) + .expect({ + name: "UnhandledRequestError", + message: + "An unexpected error occurred while performing the operation" + }); + }); + + it("logs other errors", async () => { + const logError = sinon.stub(); + const error = new Error(); + await request(getServerThrowing(error, logError)) + .get("/") + .expect(500) + .expect({ + name: "UnhandledRequestError", + message: + "An unexpected error occurred while performing the operation" + }); + expect(logError).to.have.been.calledOnceWith( + "unhandled request error", + { error } + ); + }); +}); diff --git a/http-adapters/test/managementApiAdapter/routes/CreateExternalCache.ts b/http-adapters/test/managementApiAdapter/routes/CreateExternalCache.ts new file mode 100644 index 00000000..5effd5fe --- /dev/null +++ b/http-adapters/test/managementApiAdapter/routes/CreateExternalCache.ts @@ -0,0 +1,37 @@ +import { expect } from "chai"; +import sinon from "sinon"; +import request from "supertest"; + +import { getManagementApiAdapter } from "../../testUtils"; + +describe("managementApiAdapter POST /externalCaches", () => { + it("400 on invalid request body", () => { + const server = getManagementApiAdapter({}); + return request(server) + .post("/externalCaches") + .send({}) + .expect(400) + .expect(/Validation failed/); + }); + + it("201 on externalCache created, creates and returns the externalCache", async () => { + const execMock = sinon.stub().resolves({ + domain: "domain.com", + type: "type", + configuration: {} + }); + const server = getManagementApiAdapter({ + createExternalCache: execMock + }); + await request(server) + .post("/externalCaches") + .send({ domain: "domain.com", type: "type", configuration: {} }) + .expect(201) + .expect({ domain: "domain.com", type: "type", configuration: {} }); + expect(execMock).to.have.been.calledOnceWith({ + domain: "domain.com", + type: "type", + configuration: {} + }); + }); +}); diff --git a/http-adapters/test/managementApiAdapter/routes/DeleteExternalCache.ts b/http-adapters/test/managementApiAdapter/routes/DeleteExternalCache.ts new file mode 100644 index 00000000..0fd81378 --- /dev/null +++ b/http-adapters/test/managementApiAdapter/routes/DeleteExternalCache.ts @@ -0,0 +1,18 @@ +import { expect } from "chai"; +import sinon from "sinon"; +import request from "supertest"; + +import { getManagementApiAdapter } from "../../testUtils"; + +describe("managementApiAdapter DELETE /externalCaches/:externalCacheId", () => { + it("204 on externalCache deleted, deletes the externalCache", async () => { + const execMock = sinon.stub().resolves(); + const server = getManagementApiAdapter({ + deleteExternalCache: execMock + }); + await request(server) + .delete("/externalCaches/id") + .expect(204); + expect(execMock).to.have.been.calledOnceWith("id"); + }); +}); diff --git a/http-adapters/test/managementApiAdapter/routes/GetExternalCache.ts b/http-adapters/test/managementApiAdapter/routes/GetExternalCache.ts new file mode 100644 index 00000000..d9042426 --- /dev/null +++ b/http-adapters/test/managementApiAdapter/routes/GetExternalCache.ts @@ -0,0 +1,15 @@ +import sinon from "sinon"; +import request from "supertest"; + +import { getManagementApiAdapter } from "../../testUtils"; + +describe("managementApiAdapter GET /externalCaches/:externalCacheId", () => { + it("200 and returns the externalCache", () => { + const execMock = sinon.stub().resolves({ id: "id" }); + const server = getManagementApiAdapter({ getExternalCache: execMock }); + return request(server) + .get("/externalCaches/id") + .expect(200) + .expect({ id: "id" }); + }); +}); diff --git a/http-adapters/test/managementApiAdapter/routes/GetExternalCaches.ts b/http-adapters/test/managementApiAdapter/routes/GetExternalCaches.ts new file mode 100644 index 00000000..5c77dfec --- /dev/null +++ b/http-adapters/test/managementApiAdapter/routes/GetExternalCaches.ts @@ -0,0 +1,15 @@ +import sinon from "sinon"; +import request from "supertest"; + +import { getManagementApiAdapter } from "../../testUtils"; + +describe("managementApiAdapter GET /externalCaches", () => { + it("200 and returns all externalCaches", () => { + const execMock = sinon.stub().resolves([]); + const server = getManagementApiAdapter({ getExternalCaches: execMock }); + return request(server) + .get("/externalCaches") + .expect(200) + .expect([]); + }); +}); diff --git a/http-adapters/test/managementApiAdapter/routes/GetSupportedExternalCacheTypes.ts b/http-adapters/test/managementApiAdapter/routes/GetSupportedExternalCacheTypes.ts new file mode 100644 index 00000000..d1054cc5 --- /dev/null +++ b/http-adapters/test/managementApiAdapter/routes/GetSupportedExternalCacheTypes.ts @@ -0,0 +1,17 @@ +import sinon from "sinon"; +import request from "supertest"; + +import { getManagementApiAdapter } from "../../testUtils"; + +describe("managementApiAdapter GET /supportedExternalCacheTypes", () => { + it("200 and returns all supported externalCacheTypes", () => { + const execMock = sinon.stub().resolves([]); + const server = getManagementApiAdapter({ + getSupportedExternalCacheTypes: execMock + }); + return request(server) + .get("/supportedExternalCacheTypes") + .expect(200) + .expect([]); + }); +}); diff --git a/http-adapters/test/managementApiAdapter/routes/PurgeExternalCache.ts b/http-adapters/test/managementApiAdapter/routes/PurgeExternalCache.ts new file mode 100644 index 00000000..f76fd9e5 --- /dev/null +++ b/http-adapters/test/managementApiAdapter/routes/PurgeExternalCache.ts @@ -0,0 +1,28 @@ +import { expect } from "chai"; +import sinon from "sinon"; +import request from "supertest"; + +import { getManagementApiAdapter } from "../../testUtils"; + +describe("managementApiAdapter PURGE /externalCaches/:externalCacheId", () => { + it("400 on invalid request body", () => { + const server = getManagementApiAdapter({}); + return request(server) + .purge("/externalCaches/id") + .send([]) + .expect(400) + .expect(/Validation failed/); + }); + + it("202 on externalCache purged, invokes the usecase correctly", async () => { + const execMock = sinon.stub().resolves(undefined); + const server = getManagementApiAdapter({ + purgeExternalCache: execMock + }); + await request(server) + .purge("/externalCaches/id") + .send(["/*"]) + .expect(202); + expect(execMock).to.have.been.calledOnceWith("id", ["/*"]); + }); +}); diff --git a/http-adapters/test/managementApiAdapter/routes/UpdateExternalCache.ts b/http-adapters/test/managementApiAdapter/routes/UpdateExternalCache.ts new file mode 100644 index 00000000..2d583ed7 --- /dev/null +++ b/http-adapters/test/managementApiAdapter/routes/UpdateExternalCache.ts @@ -0,0 +1,33 @@ +import { expect } from "chai"; +import sinon from "sinon"; +import request from "supertest"; + +import { getManagementApiAdapter } from "../../testUtils"; + +describe("managementApiAdapter PATCH /externalCaches/:externalCacheId", () => { + it("400 on invalid request body", () => { + const server = getManagementApiAdapter({}); + return request(server) + .patch("/externalCaches/id") + .send({ invalidKey: "invalidKey" }) + .expect(400) + .expect(/Validation failed/); + }); + + it("200 on externalCache updated, updates externalCache and returns it", async () => { + const execMock = sinon + .stub() + .resolves({ configuration: { newKey: "newValue" } }); + const server = getManagementApiAdapter({ + updateExternalCache: execMock + }); + await request(server) + .patch("/externalCaches/id") + .send({ configuration: { newKey: "newValue" } }) + .expect(200) + .expect({ configuration: { newKey: "newValue" } }); + expect(execMock).to.have.been.calledOnceWith("id", { + configuration: { newKey: "newValue" } + }); + }); +}); diff --git a/http-adapters/test/testUtils.ts b/http-adapters/test/testUtils.ts index c79d2eed..988c81b8 100644 --- a/http-adapters/test/testUtils.ts +++ b/http-adapters/test/testUtils.ts @@ -44,6 +44,11 @@ function getServer( ) { return express() .use((req: IBaseRequest, _res, next) => { + req.log = { + addToContext: sinon.stub(), + error: sinon.stub(), + info: sinon.stub() + }; req.makeUsecase = (name: string) => ({ exec: execMocks[name] } as any); next(); diff --git a/http-adapters/tsconfig.json b/http-adapters/tsconfig.json index 35ce5339..8d4bc151 100644 --- a/http-adapters/tsconfig.json +++ b/http-adapters/tsconfig.json @@ -5,6 +5,7 @@ "types": [ "@types/chai", "@types/express", + "@types/lodash", "@types/mocha", "@types/sinon", "@types/sinon-chai", diff --git a/management-console/mock-server/externalCaches/get.ts b/management-console/mock-server/externalCaches/get.ts new file mode 100644 index 00000000..da4d3807 --- /dev/null +++ b/management-console/mock-server/externalCaches/get.ts @@ -0,0 +1,11 @@ +import { RequestHandler } from "express"; + +import { externalCache, times } from "../generators"; + +export default ((_req, res) => { + if (Math.random() > 0.9) { + res.status(400).send({ message: "Random error" }); + } else { + res.status(200).send(times(10, externalCache)); + } +}) as RequestHandler; diff --git a/management-console/mock-server/externalCaches/post.ts b/management-console/mock-server/externalCaches/post.ts new file mode 100644 index 00000000..6b041b91 --- /dev/null +++ b/management-console/mock-server/externalCaches/post.ts @@ -0,0 +1,13 @@ +import { RequestHandler } from "express"; + +import { externalCache } from "../generators"; + +export default ((req, res) => { + res.status(201).send( + externalCache({ + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...req.body + }) + ); +}) as RequestHandler; diff --git a/management-console/mock-server/externalCaches/{externalCacheId}/delete.ts b/management-console/mock-server/externalCaches/{externalCacheId}/delete.ts new file mode 100644 index 00000000..d81cdc53 --- /dev/null +++ b/management-console/mock-server/externalCaches/{externalCacheId}/delete.ts @@ -0,0 +1,5 @@ +import { RequestHandler } from "express"; + +export default ((_req, res) => { + res.status(204).send(); +}) as RequestHandler; diff --git a/management-console/mock-server/externalCaches/{externalCacheId}/get.ts b/management-console/mock-server/externalCaches/{externalCacheId}/get.ts new file mode 100644 index 00000000..8794449b --- /dev/null +++ b/management-console/mock-server/externalCaches/{externalCacheId}/get.ts @@ -0,0 +1,7 @@ +import { RequestHandler } from "express"; + +import { externalCache } from "../../generators"; + +export default ((req, res) => { + res.status(200).send(externalCache({ id: req.params.externalCacheId })); +}) as RequestHandler; diff --git a/management-console/mock-server/externalCaches/{externalCacheId}/patch.ts b/management-console/mock-server/externalCaches/{externalCacheId}/patch.ts new file mode 100644 index 00000000..67eb428c --- /dev/null +++ b/management-console/mock-server/externalCaches/{externalCacheId}/patch.ts @@ -0,0 +1,13 @@ +import { RequestHandler } from "express"; + +import { externalCache } from "../../generators"; + +export default ((req, res) => { + res.status(200).send( + externalCache({ + id: req.params.externalCacheId, + updatedAt: new Date().toISOString(), + ...req.body + }) + ); +}) as RequestHandler; diff --git a/management-console/mock-server/generators.ts b/management-console/mock-server/generators.ts index 60c4d318..91f2e450 100644 --- a/management-console/mock-server/generators.ts +++ b/management-console/mock-server/generators.ts @@ -51,6 +51,94 @@ export const entrypoint = (supplied: any = {}) => ({ ...supplied }); +// External caches +export const externalCache = (supplied: any = {}) => { + const type = faker.random.arrayElement(["AWS_CLOUDFRONT", "AZURE_CDN"]); + return { + id: id(), + domain: faker.internet.domainName(), + type: type, + configuration: + type === "AWS_CLOUDFRONT" + ? { + ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE", + SECRET_ACCESS_KEY: + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKE", + CLOUDFRONT_DISTRIBUTION_ID: "EDFDVBD6EXAMPLE" + } + : { + SERVICE_PRINCIPAL_USERNAME: "Username", + SERVICE_PRINCIPAL_PASSWORD: "Password", + SUBSCRIPTION_ID: "3a6f930e-e2bb-4420-aa6e-d7c9ae5dda0c", + RESOURCE_GROUP_NAME: "resource-group-name", + CDN_PROFILE_NAME: "cdn-profile-name", + CDN_ENDPOINT_NAME: "cdn-endpoint-name" + }, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...supplied + }; +}; +export const externalCacheTypes = () => [ + { + name: "AWS_CLOUDFRONT", + label: "AWS CloudFront", + configurationFields: [ + { + name: "ACCESS_KEY_ID", + label: "Access Key Id", + placeholder: "AKIAIOSFODNN7EXAMPLE" + }, + { + name: "SECRET_ACCESS_KEY", + label: "Secret Access Key", + placeholder: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + }, + { + name: "CLOUDFRONT_DISTRIBUTION_ID", + label: "CloudFront Distribution Id", + placeholder: "EDFDVBD6EXAMPLE" + } + ] + }, + { + name: "AZURE_CDN", + label: "Azure CDN", + configurationFields: [ + { + name: "SERVICE_PRINCIPAL_USERNAME", + label: "Service Principal Username", + placeholder: "Username" + }, + { + name: "SERVICE_PRINCIPAL_PASSWORD", + label: "Service Principal Password", + placeholder: "Password" + }, + { + name: "SUBSCRIPTION_ID", + label: "Subscription Id", + placeholder: "3a6f930e-e2bb-4420-aa6e-d7c9ae5dda0c" + }, + { + name: "RESOURCE_GROUP_NAME", + label: "Resource Group Name", + placeholder: "resource-group-name" + }, + { + name: "CDN_PROFILE_NAME", + label: "CDN Profile Name", + placeholder: "cdn-profile-name" + }, + { + name: "CDN_ENDPOINT_NAME", + label: "CDN Endpoint Name", + placeholder: "cdn-endpoint-name" + } + ] + } +]; + // Groups export const group = (supplied: any = {}) => ({ id: id(), diff --git a/management-console/mock-server/supportedExternalCacheTypes/get.ts b/management-console/mock-server/supportedExternalCacheTypes/get.ts new file mode 100644 index 00000000..75d066db --- /dev/null +++ b/management-console/mock-server/supportedExternalCacheTypes/get.ts @@ -0,0 +1,11 @@ +import { RequestHandler } from "express"; + +import { externalCacheTypes } from "../generators"; + +export default ((_req, res) => { + if (Math.random() > 0.9) { + res.status(400).send({ message: "Random error" }); + } else { + res.status(200).send(externalCacheTypes()); + } +}) as RequestHandler; diff --git a/management-console/package.json b/management-console/package.json index 12600bc6..64821565 100644 --- a/management-console/package.json +++ b/management-console/package.json @@ -31,7 +31,7 @@ "dependencies": { "@staticdeploy/core": "^0.15.0", "@staticdeploy/sdk": "^0.15.0", - "antd": "^3.26.7", + "antd": "^3.26.8", "classnames": "^2.2.6", "eventemitter3": "^4.0.0", "jwt-decode": "^2.2.0", diff --git a/management-console/src/Root.tsx b/management-console/src/Root.tsx index b477eaf6..b8c95db6 100644 --- a/management-console/src/Root.tsx +++ b/management-console/src/Root.tsx @@ -7,6 +7,7 @@ import LoginMask from "./components/LoginMask"; import Sider from "./components/Sider"; import Apps from "./pages/Apps"; import Bundles from "./pages/Bundles"; +import ExternalCaches from "./pages/ExternalCaches"; import Groups from "./pages/Groups"; import OperationLogs from "./pages/OperationLogs"; import Users from "./pages/Users"; @@ -32,6 +33,10 @@ export default class Root extends React.Component { /> + { initialValues?: Partial; } -interface IConfig { +interface IConfig { form: string; touchOnBlur?: boolean; - validate?: (values: InternalValues) => FormErrors; + validate?: ( + values: InternalValues, + props: IInjectedFormProps & AdditionalProps + ) => FormErrors; toInternal?: (values: any) => InternalValues; toExternal?: (values: InternalValues) => ExternalValues; + onChange?( + currentValues: Partial, + previousValues: Partial, + change: (field: string, value: any) => void + ): void; +} + +export interface IInjectedFormProps + extends InjectedFormProps { + getValues: () => Partial | undefined; } export interface IConverterForm { @@ -28,20 +41,30 @@ export interface IConverterForm { getValues: () => ExternalValues; } -export function reduxForm( - config: IConfig -) { +export function reduxForm< + ExternalValues, + InternalValues = ExternalValues, + AdditionalProps = {} +>(config: IConfig) { const toInternal: any = config.toInternal || identity; const toExternal: any = config.toExternal || identity; return ( Form: React.ComponentType< - InjectedFormProps & AdditionalProps + IInjectedFormProps & AdditionalProps > ) => { const DecoratedForm = wrappedReduxForm({ form: config.form, validate: config.validate, - touchOnBlur: config.touchOnBlur !== false + touchOnBlur: config.touchOnBlur !== false, + onChange: config.onChange + ? (values, dispatch, props, previousValues) => { + // TODO: fix when erikras/redux-form #4110 is solved + config.onChange!(values, previousValues, (...args) => + dispatch(props.change(...args)) + ); + } + : undefined })(Form as any); return class ConverterForm extends React.Component & AdditionalProps> @@ -65,6 +88,7 @@ export function reduxForm( return ( this.form && this.form.values} ref={form => (this.form = form!)} initialValues={toInternal(initialValues)} onSubmit={values => diff --git a/management-console/src/components/AppForm/index.tsx b/management-console/src/components/AppForm/index.tsx index 5c3a794c..898ec919 100644 --- a/management-console/src/components/AppForm/index.tsx +++ b/management-console/src/components/AppForm/index.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { InjectedFormProps } from "redux-form"; import { fromKVPairs, toKVPairs } from "../../common/configurationUtils"; import { IConverterForm, + IInjectedFormProps, reduxForm } from "../../common/formWithValuesConverter"; import ConfigurationField from "../ConfigurationField"; @@ -16,7 +16,7 @@ interface IProps { } class AppForm extends React.Component< - IProps & InjectedFormProps + IProps & IInjectedFormProps > { render() { const { isEditForm } = this.props; @@ -42,11 +42,9 @@ export interface IAppFormInstance extends IConverterForm {} export default reduxForm({ form: "AppForm", validate: validate, - toInternal: (initialValues = {}) => ({ - name: initialValues.name || "", - defaultConfiguration: toKVPairs( - initialValues.defaultConfiguration || {} - ) + toInternal: initialValues => ({ + name: initialValues.name, + defaultConfiguration: toKVPairs(initialValues.defaultConfiguration) }), toExternal: values => ({ name: values.name, diff --git a/management-console/src/components/ConfigurationField/index.tsx b/management-console/src/components/ConfigurationField/index.tsx index df1262da..90827bd7 100644 --- a/management-console/src/components/ConfigurationField/index.tsx +++ b/management-console/src/components/ConfigurationField/index.tsx @@ -14,19 +14,12 @@ import "./index.css"; interface IProps { name: string; - label?: string; + label: string; } export class WrappedConfigurationField extends React.Component< IProps & WrappedFieldArrayProps > { - renderLabel() { - return this.props.label ? ( -
- -
- ) : null; - } renderKVPairFields( fieldName: string, index: number, @@ -58,10 +51,12 @@ export class WrappedConfigurationField extends React.Component< ); } render() { - const { fields } = this.props; + const { fields, label } = this.props; return (
- {this.renderLabel()} +
+ +
{fields.map(this.renderKVPairFields)}