diff --git a/core/src/config/module-template.ts b/core/src/config/module-template.ts index 814ddc827f..188ff9c72f 100644 --- a/core/src/config/module-template.ts +++ b/core/src/config/module-template.ts @@ -54,7 +54,10 @@ export async function resolveModuleTemplate( ...resource, modules: [], } - const context = new ProjectConfigContext({ ...garden, branch: garden.vcsBranch }) + const branch = garden.vcsBranch + const loggedIn = !!garden.enterpriseApi + const enterpriseDomain = garden.enterpriseApi?.domain + const context = new ProjectConfigContext({ ...garden, branch, loggedIn, enterpriseDomain }) const resolved = resolveTemplateStrings(partial, context) // Validate the partial config @@ -112,7 +115,10 @@ export async function resolveTemplatedModule( // Resolve template strings for fields. Note that inputs are partially resolved, and will be fully resolved later // when resolving the resolving the resulting modules. Inputs that are used in module names must however be resolvable // immediately. - const templateContext = new EnvironmentConfigContext({ ...garden, branch: garden.vcsBranch }) + const branch = garden.vcsBranch + const loggedIn = !!garden.enterpriseApi + const enterpriseDomain = garden.enterpriseApi?.domain + const templateContext = new EnvironmentConfigContext({ ...garden, branch, loggedIn, enterpriseDomain }) const resolvedWithoutInputs = resolveTemplateStrings( { ...config, spec: omit(config.spec, "inputs") }, templateContext @@ -160,6 +166,8 @@ export async function resolveTemplatedModule( const context = new ModuleTemplateConfigContext({ ...garden, branch: garden.vcsBranch, + loggedIn: !!garden.enterpriseApi, + enterpriseDomain, parentName: resolved.name, templateName: template.name, inputs: partiallyResolvedInputs, diff --git a/core/src/config/project.ts b/core/src/config/project.ts index eebd1da680..fc6859f310 100644 --- a/core/src/config/project.ts +++ b/core/src/config/project.ts @@ -403,6 +403,8 @@ export function resolveProjectConfig({ artifactsPath, branch, username, + loggedIn, + enterpriseDomain, secrets, commandInfo, }: { @@ -411,6 +413,8 @@ export function resolveProjectConfig({ artifactsPath: string branch: string username: string + loggedIn: boolean + enterpriseDomain: string | undefined secrets: PrimitiveMap commandInfo: CommandInfo }): ProjectConfig { @@ -431,6 +435,8 @@ export function resolveProjectConfig({ artifactsPath, branch, username, + loggedIn, + enterpriseDomain, secrets, commandInfo, }) @@ -511,6 +517,8 @@ export async function pickEnvironment({ artifactsPath, branch, username, + loggedIn, + enterpriseDomain, secrets, commandInfo, }: { @@ -519,6 +527,8 @@ export async function pickEnvironment({ artifactsPath: string branch: string username: string + loggedIn: boolean + enterpriseDomain: string | undefined secrets: PrimitiveMap commandInfo: CommandInfo }) { @@ -552,6 +562,8 @@ export async function pickEnvironment({ branch, username, variables: projectVariables, + loggedIn, + enterpriseDomain, secrets, commandInfo, }) diff --git a/core/src/config/template-contexts/base.ts b/core/src/config/template-contexts/base.ts index 25cb9724bc..8f5c2f431a 100644 --- a/core/src/config/template-contexts/base.ts +++ b/core/src/config/template-contexts/base.ts @@ -69,6 +69,14 @@ export abstract class ConfigContext { return joi.object().keys(schemas).required() } + /** + * Override this method to add more context to error messages thrown in the `resolve` method when a missing key is + * referenced. + */ + getMissingKeyErrorFooter(_key: ContextKeySegment, _path: ContextKeySegment[]): string { + return "" + } + resolve({ key, nodePath, opts }: ContextResolveParams): ContextResolveOutput { const path = renderKeyPath(key) const fullPath = renderKeyPath(nodePath.concat(key)) @@ -178,6 +186,10 @@ export abstract class ConfigContext { if (available && available.length) { message += chalk.red(" Available keys: " + naturalList(available.sort().map((k) => chalk.white(k))) + ".") } + const messageFooter = this.getMissingKeyErrorFooter(nextKey, nestedNodePath.slice(0, -1)) + if (messageFooter) { + message += `\n\n${messageFooter}` + } } // If we're allowing partial strings, we throw the error immediately to end the resolution flow. The error diff --git a/core/src/config/template-contexts/project.ts b/core/src/config/template-contexts/project.ts index 92418f4eea..269e99ca5b 100644 --- a/core/src/config/template-contexts/project.ts +++ b/core/src/config/template-contexts/project.ts @@ -6,10 +6,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { last, isEmpty } from "lodash" +import chalk from "chalk" import { PrimitiveMap, joiIdentifierMap, joiStringMap, joiPrimitive, DeepPrimitiveMap, joiVariables } from "../common" import { joi } from "../common" import { deline, dedent } from "../../util/string" -import { schema, ConfigContext } from "./base" +import { schema, ConfigContext, ContextKeySegment } from "./base" import { CommandInfo } from "../../plugin-context" class LocalContext extends ConfigContext { @@ -200,7 +202,9 @@ export class DefaultEnvironmentContext extends ConfigContext { } export interface ProjectConfigContextParams extends DefaultEnvironmentContextParams { + loggedIn: boolean secrets: PrimitiveMap + enterpriseDomain: string | undefined } /** @@ -220,10 +224,44 @@ export class ProjectConfigContext extends DefaultEnvironmentContext { }) ) public secrets: PrimitiveMap + private _enterpriseDomain: string | undefined + private _loggedIn: boolean + + getMissingKeyErrorFooter(_key: ContextKeySegment, path: ContextKeySegment[]): string { + if (last(path) !== "secrets") { + return "" + } + + if (!this._loggedIn) { + return dedent` + You are not logged in to Garden Enterprise, but one or more secrets are referenced in template strings in your Garden configuration files. + + Please log in via the ${chalk.green("garden login")} command to use Garden with secrets. + ` + } + + if (isEmpty(this.secrets)) { + // TODO: Provide project ID (not UID) to this class so we can render a full link to the secrets section of the + // project. To do this, we'll also need to handle the case where the project doesn't already exist in GE/CLoud. + const suffix = this._enterpriseDomain + ? ` To create secrets, please visit ${this._enterpriseDomain} and navigate to the secrets section for this project.` + : "" + return deline` + Looks like no secrets have been created for this project and/or environment in Garden Enterprise.${suffix} + ` + } else { + return deline` + Please make sure that all required secrets for this project exist in Garden Enterprise, and are accessible in this + environment. + ` + } + } constructor(params: ProjectConfigContextParams) { super(params) + this._loggedIn = params.loggedIn this.secrets = params.secrets + this._enterpriseDomain = params.enterpriseDomain } } diff --git a/core/src/config/template-contexts/workflow.ts b/core/src/config/template-contexts/workflow.ts index 070d261fc7..74ee300ac2 100644 --- a/core/src/config/template-contexts/workflow.ts +++ b/core/src/config/template-contexts/workflow.ts @@ -40,6 +40,8 @@ export class WorkflowConfigContext extends EnvironmentConfigContext { branch: garden.vcsBranch, username: garden.username, variables: garden.variables, + loggedIn: !!garden.enterpriseApi, + enterpriseDomain: garden.enterpriseApi?.domain, secrets: garden.secrets, commandInfo: garden.commandInfo, }) diff --git a/core/src/enterprise/api.ts b/core/src/enterprise/api.ts index 6c8bfe7ce8..2fd4ce9e74 100644 --- a/core/src/enterprise/api.ts +++ b/core/src/enterprise/api.ts @@ -27,8 +27,12 @@ export const authTokenHeader = export const makeAuthHeader = (clientAuthToken: string) => ({ [authTokenHeader]: clientAuthToken }) +export function isGotError(error: any, statusCode: number): error is GotHttpError { + return error instanceof GotHttpError && error.response.statusCode === statusCode +} + function is401Error(error: any): error is GotHttpError { - return error instanceof GotHttpError && error.response.statusCode === 401 + return isGotError(error, 401) } const refreshThreshold = 10 // Threshold (in seconds) subtracted to jwt validity when checking if a refresh is needed diff --git a/core/src/enterprise/get-secrets.ts b/core/src/enterprise/get-secrets.ts index 5ff35b2567..992e43284e 100644 --- a/core/src/enterprise/get-secrets.ts +++ b/core/src/enterprise/get-secrets.ts @@ -8,8 +8,9 @@ import { LogEntry } from "../logger/log-entry" import { StringMap } from "../config/common" -import { EnterpriseApi } from "./api" +import { EnterpriseApi, isGotError } from "./api" import { BaseResponse } from "@garden-io/platform-api-types" +import { deline } from "../util/string" export interface GetSecretsParams { log: LogEntry @@ -26,9 +27,22 @@ export async function getSecrets({ log, environmentName, enterpriseApi }: GetSec ) secrets = res.data } catch (err) { - log.error("An error occurred while fetching secrets for the project.") - log.debug(`Error: ${err.message}`) - throw err + if (isGotError(err, 404)) { + log.debug("No secrets were received from Garden Enterprise.") + log.debug("") + log.debug(deline` + Either the environment ${environmentName} does not exist in Garden Enterprise, or no project + with the id in your project configuration exists in Garden Enterprise. + `) + log.debug("") + log.debug(deline` + Please visit ${enterpriseApi.domain} to review the environments and projects currently + in the system. + `) + } else { + log.error("An error occurred while fetching secrets for the project.") + throw err + } } const emptyKeys = Object.keys(secrets).filter((key) => !secrets[key]) diff --git a/core/src/garden.ts b/core/src/garden.ts index bf5f7d032d..ae8d1d31ad 100644 --- a/core/src/garden.ts +++ b/core/src/garden.ts @@ -1211,12 +1211,17 @@ export async function resolveGardenParams(currentDirectory: string, opts: Garden } } + const loggedIn = !!enterpriseApi + const enterpriseDomain = enterpriseApi?.domain + config = resolveProjectConfig({ defaultEnvironment: defaultEnvironmentName, config, artifactsPath, branch: vcsBranch, username: _username, + loggedIn, + enterpriseDomain, secrets, commandInfo, }) @@ -1229,6 +1234,8 @@ export async function resolveGardenParams(currentDirectory: string, opts: Garden artifactsPath, branch: vcsBranch, username: _username, + loggedIn, + enterpriseDomain, secrets, commandInfo, }) diff --git a/core/test/unit/src/config/project.ts b/core/test/unit/src/config/project.ts index 128132b6ce..c2f6571faf 100644 --- a/core/test/unit/src/config/project.ts +++ b/core/test/unit/src/config/project.ts @@ -29,9 +29,11 @@ import { resolve, join } from "path" import stripAnsi from "strip-ansi" import { keyBy } from "lodash" +const enterpriseDomain = "https://garden.mydomain.com" const commandInfo = { name: "test", args: {}, opts: {} } describe("resolveProjectConfig", () => { + it("should pass through a canonical project config", async () => { const defaultEnvironment = "default" const config: ProjectConfig = { @@ -54,6 +56,8 @@ describe("resolveProjectConfig", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -93,6 +97,8 @@ describe("resolveProjectConfig", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -148,6 +154,8 @@ describe("resolveProjectConfig", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: { foo: "banana" }, commandInfo, }) @@ -223,6 +231,8 @@ describe("resolveProjectConfig", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -287,6 +297,8 @@ describe("resolveProjectConfig", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -316,6 +328,8 @@ describe("resolveProjectConfig", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -350,6 +364,8 @@ describe("resolveProjectConfig", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -403,6 +419,8 @@ describe("resolveProjectConfig", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -479,6 +497,8 @@ describe("resolveProjectConfig", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -550,6 +570,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }), @@ -577,6 +599,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -621,6 +645,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -664,6 +690,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -722,6 +750,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -776,6 +806,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -824,6 +856,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -873,6 +907,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -922,6 +958,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -982,6 +1020,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -1043,6 +1083,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -1076,6 +1118,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: { foo: "banana" }, commandInfo, }) @@ -1108,6 +1152,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -1139,6 +1185,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -1165,6 +1213,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -1225,6 +1275,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -1267,6 +1319,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }), @@ -1305,6 +1359,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }), @@ -1340,6 +1396,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }), @@ -1367,6 +1425,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -1399,6 +1459,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -1431,6 +1493,8 @@ describe("pickEnvironment", () => { artifactsPath, username, branch: "main", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }) @@ -1464,6 +1528,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }), @@ -1495,6 +1561,8 @@ describe("pickEnvironment", () => { artifactsPath, branch: "main", username, + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo, }), diff --git a/core/test/unit/src/config/template-contexts/project.ts b/core/test/unit/src/config/template-contexts/project.ts index bce2939523..82201a1c5d 100644 --- a/core/test/unit/src/config/template-contexts/project.ts +++ b/core/test/unit/src/config/template-contexts/project.ts @@ -11,6 +11,7 @@ import stripAnsi = require("strip-ansi") import { ConfigContext } from "../../../../../src/config/template-contexts/base" import { ProjectConfigContext } from "../../../../../src/config/template-contexts/project" import { resolveTemplateString } from "../../../../../src/template-string/template-string" +import { deline } from "../../../../../src/util/string" type TestValue = string | ConfigContext | TestValues | TestValueFunction type TestValueFunction = () => TestValue | Promise @@ -19,6 +20,8 @@ interface TestValues { } describe("ProjectConfigContext", () => { + const enterpriseDomain = "https://garden.mydomain.com" + it("should resolve local env variables", () => { process.env.TEST_VARIABLE = "value" const c = new ProjectConfigContext({ @@ -27,6 +30,8 @@ describe("ProjectConfigContext", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo: { name: "test", args: {}, opts: {} }, }) @@ -43,6 +48,8 @@ describe("ProjectConfigContext", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo: { name: "test", args: {}, opts: {} }, }) @@ -51,13 +58,15 @@ describe("ProjectConfigContext", () => { }) }) - it("should resolve secrets", () => { + it("should resolve when logged in", () => { const c = new ProjectConfigContext({ projectName: "some-project", projectRoot: "/tmp", artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: { foo: "banana" }, commandInfo: { name: "test", args: {}, opts: {} }, }) @@ -66,6 +75,72 @@ describe("ProjectConfigContext", () => { }) }) + context("errors thrown when a missing secret is referenced", () => { + it("should ask the user to log in if they're logged out", () => { + const c = new ProjectConfigContext({ + projectName: "some-project", + projectRoot: "/tmp", + artifactsPath: "/tmp", + branch: "main", + username: "some-user", + loggedIn: false, // <----- + enterpriseDomain, + secrets: { foo: "banana" }, + commandInfo: { name: "test", args: {}, opts: {} }, + }) + + const { message } = c.resolve({ key: ["secrets", "bar"], nodePath: [], opts: {} }) + + expect(stripAnsi(message!)).to.match(/Please log in via the garden login command to use Garden with secrets/) + }) + + context("when logged in", () => { + it("should notify the user if an empty set of secrets was returned by the backend", () => { + const c = new ProjectConfigContext({ + projectName: "some-project", + projectRoot: "/tmp", + artifactsPath: "/tmp", + branch: "main", + username: "some-user", + loggedIn: true, + enterpriseDomain, + secrets: {}, // <----- + commandInfo: { name: "test", args: {}, opts: {} }, + }) + + const { message } = c.resolve({ key: ["secrets", "bar"], nodePath: [], opts: {} }) + + const errMsg = deline` + Looks like no secrets have been created for this project and/or environment in Garden Enterprise. + To create secrets, please visit ${enterpriseDomain} and navigate to the secrets section for this project. + ` + expect(stripAnsi(message!)).to.match(new RegExp(errMsg)) + }) + + it("if a non-empty set of secrets was returned by the backend, provide a helpful suggestion", () => { + const c = new ProjectConfigContext({ + projectName: "some-project", + projectRoot: "/tmp", + artifactsPath: "/tmp", + branch: "main", + username: "some-user", + loggedIn: true, + enterpriseDomain, + secrets: { foo: "banana " }, // <----- + commandInfo: { name: "test", args: {}, opts: {} }, + }) + + const { message } = c.resolve({ key: ["secrets", "bar"], nodePath: [], opts: {} }) + + const errMsg = deline` + Please make sure that all required secrets for this project exist in Garden Enterprise, and are accessible in this + environment. + ` + expect(stripAnsi(message!)).to.match(new RegExp(errMsg)) + }) + }) + }) + it("should return helpful message when resolving missing env variable", () => { const c = new ProjectConfigContext({ projectName: "some-project", @@ -73,6 +148,8 @@ describe("ProjectConfigContext", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo: { name: "test", args: {}, opts: {} }, }) @@ -92,6 +169,8 @@ describe("ProjectConfigContext", () => { artifactsPath: "/tmp", branch: "main", username: "some-user", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo: { name: "test", args: {}, opts: {} }, }) @@ -107,6 +186,8 @@ describe("ProjectConfigContext", () => { artifactsPath: "/tmp", branch: "main", username: "SomeUser", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo: { name: "test", args: {}, opts: {} }, }) @@ -125,6 +206,8 @@ describe("ProjectConfigContext", () => { artifactsPath: "/tmp", branch: "main", username: "SomeUser", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo: { name: "test", args: {}, opts: {} }, }) @@ -140,6 +223,8 @@ describe("ProjectConfigContext", () => { artifactsPath: "/tmp", branch: "main", username: "SomeUser", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo: { name: "deploy", args: {}, opts: { "hot-reload": ["my-service"] } }, }) @@ -158,6 +243,8 @@ describe("ProjectConfigContext", () => { artifactsPath: "/tmp", branch: "main", username: "SomeUser", + loggedIn: true, + enterpriseDomain, secrets: {}, commandInfo: { name: "test", args: {}, opts: {} }, }) diff --git a/core/test/unit/src/garden.ts b/core/test/unit/src/garden.ts index 39ca51438b..2d764e4ee2 100644 --- a/core/test/unit/src/garden.ts +++ b/core/test/unit/src/garden.ts @@ -1556,30 +1556,6 @@ describe("Garden", () => { await expectError(() => garden.resolveProviders(garden.log)) }) - it.skip("should throw if providers reference missing secrets in template strings", async () => { - const test = createGardenPlugin({ - name: "test", - }) - - const projectConfig: ProjectConfig = { - apiVersion: "garden.io/v0", - kind: "Project", - name: "test", - path: projectRootA, - defaultEnvironment: "default", - dotIgnoreFiles: defaultDotIgnoreFiles, - environments: [{ name: "default", defaultNamespace, variables: {} }], - providers: [{ name: "test", foo: "${secrets.missing}" }], // < ------ - variables: {}, - } - - const garden = await TestGarden.factory(projectRootA, { config: projectConfig, plugins: [test] }) - await expectError( - () => garden.resolveProviders(garden.log), - (err) => expect(err.message).to.match(/Provider test: missing/) - ) - }) - it("should add plugin modules if returned by the provider", async () => { const pluginModule: ModuleConfig = { apiVersion: DEFAULT_API_VERSION,