diff --git a/packages/back-end/generated/spec.yaml b/packages/back-end/generated/spec.yaml
index 4a2990458a7..f15c8697dd2 100644
--- a/packages/back-end/generated/spec.yaml
+++ b/packages/back-end/generated/spec.yaml
@@ -60,6 +60,9 @@ tags:
- name: projects
x-displayName: Projects
description: Projects are used to organize your feature flags and experiments
+ - name: environments
+ x-displayName: Environments
+ description: 'GrowthBook comes with one environment by default (production), but you can add as many as you need. When used with feature flags, you can enable/disable feature flags on a per-environment basis.'
- name: features
x-displayName: Feature Flags
description: Control your feature flags programatically
@@ -105,6 +108,9 @@ tags:
- name: Dimension_model
x-displayName: Dimension
description:
+ - name: Environment_model
+ x-displayName: Environment
+ description:
- name: Experiment_model
x-displayName: Experiment
description:
@@ -2437,6 +2443,146 @@ paths:
properties:
organization:
$ref: '#/components/schemas/Organization'
+ /environments:
+ get:
+ summary: Get the organization's environments
+ tags:
+ - environments
+ operationId: listEnvironments
+ x-codeSamples:
+ - lang: cURL
+ source: |
+ curl https://api.growthbook.io/api/v1/environments \
+ -u secret_abc123DEF456:
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ allOf:
+ - type: object
+ required:
+ - environments
+ properties:
+ environments:
+ type: array
+ items:
+ $ref: '#/components/schemas/Environment'
+ post:
+ tags:
+ - environments
+ summary: Create a new environment
+ operationId: postEnvironment
+ x-codeSamples:
+ - lang: cURL
+ source: |
+ curl -X POST https://api.growthbook.io/api/v1/environments \
+ -d '{"id": "new-env", "description": "My new environment" }' \
+ -u secret_abc123DEF456:
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - id
+ properties:
+ id:
+ type: string
+ description: The ID of the new environment
+ description:
+ type: string
+ description: The description of the new environment
+ toggleOnList:
+ type: bool
+ description: Show toggle on feature list
+ defaultState:
+ type: bool
+ description: Default state for new features
+ projects:
+ type: array
+ items:
+ type: string
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - environment
+ properties:
+ environment:
+ $ref: '#/components/schemas/Environment'
+ '/environments/${id}':
+ put:
+ parameters:
+ - $ref: '#/components/parameters/id'
+ tags:
+ - environments
+ summary: Update an environment
+ operationId: putEnvironment
+ x-codeSamples:
+ - lang: cURL
+ source: |
+ curl -X PUT https://api.growthbook.io/api/v1/environments/env-id \
+ -d '{ "description": "My updated environment" }' \
+ -u secret_abc123DEF456:
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ description:
+ type: string
+ description: The description of the new environment
+ toggleOnList:
+ type: boolean
+ description: Show toggle on feature list
+ defaultState:
+ type: boolean
+ description: Default state for new features
+ projects:
+ type: array
+ items:
+ type: string
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - environment
+ properties:
+ environment:
+ $ref: '#/components/schemas/Environment'
+ delete:
+ summary: Deletes a single environment
+ parameters:
+ - $ref: '#/components/parameters/id'
+ tags:
+ - environments
+ operationId: deleteEnvironment
+ x-codeSamples:
+ - lang: cURL
+ source: |
+ curl -X DELETE https://api.growthbook.io/api/v1/enviromnents/env-id \
+ -u secret_abc123DEF456:
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - deletedId
+ properties:
+ deletedId:
+ type: string
/fact-tables:
get:
summary: Get all fact tables
@@ -3895,6 +4041,27 @@ components:
properties:
statsEngine:
type: string
+ Environment:
+ type: object
+ required:
+ - id
+ - description
+ - toggleOnList
+ - defaultState
+ - projects
+ properties:
+ id:
+ type: string
+ description:
+ type: string
+ toggleOnList:
+ type: boolean
+ defaultState:
+ type: boolean
+ projects:
+ type: array
+ items:
+ type: string
Segment:
type: object
required:
@@ -5337,6 +5504,7 @@ x-tagGroups:
- name: Endpoints
tags:
- projects
+ - environments
- features
- data-sources
- metrics
@@ -5354,6 +5522,7 @@ x-tagGroups:
tags:
- DataSource_model
- Dimension_model
+ - Environment_model
- Experiment_model
- ExperimentAnalysisSettings_model
- ExperimentMetric_model
diff --git a/packages/back-end/package.json b/packages/back-end/package.json
index c673a765784..6b96e7c3c07 100644
--- a/packages/back-end/package.json
+++ b/packages/back-end/package.json
@@ -27,8 +27,8 @@
"generate-api-types": "yarn generate-api-models && swagger-cli bundle -t yaml src/api/openapi/openapi.tmp.yaml -o generated/spec.yaml && node src/scripts/generate-openapi.mjs"
},
"dependencies": {
- "@aws-sdk/client-sts": "^3.567.0",
"@aws-sdk/client-athena": "^3.564.0",
+ "@aws-sdk/client-sts": "^3.567.0",
"@clickhouse/client": "^1.0.1",
"@databricks/sql": "^1.8.1",
"@dqbd/tiktoken": "^1.0.7",
@@ -141,7 +141,9 @@
"delay-cli": "^2.0.0",
"jest": "^27.1.1",
"minimist": "^1.2.8",
+ "mongodb-memory-server": "^9.2.0",
"rimraf": "^3.0.2",
+ "supertest": "^7.0.0",
"typescript": "5.3.3"
},
"optionalDependencies": {
diff --git a/packages/back-end/src/api/api.router.ts b/packages/back-end/src/api/api.router.ts
index 87f046f3479..87cf883ed75 100644
--- a/packages/back-end/src/api/api.router.ts
+++ b/packages/back-end/src/api/api.router.ts
@@ -11,6 +11,7 @@ import experimentsRouter from "./experiments/experiments.router";
import metricsRouter from "./metrics/metrics.router";
import segmentsRouter from "./segments/segments.router";
import projectsRouter from "./projects/projects.router";
+import environmentsRouter from "./environments/environments.router";
import savedGroupsRouter from "./saved-groups/saved-groups.router";
import sdkConnectionsRouter from "./sdk-connections/sdk-connections.router";
import sdkPayloadRouter from "./sdk-payload/sdk-payload.router";
@@ -82,6 +83,7 @@ router.use("/metrics", metricsRouter);
router.use("/segments", segmentsRouter);
router.use("/dimensions", dimensionsRouter);
router.use("/projects", projectsRouter);
+router.use("/environments", environmentsRouter);
router.use("/sdk-connections", sdkConnectionsRouter);
router.use("/data-sources", dataSourcesRouter);
router.use("/visual-changesets", visualChangesetsRouter);
diff --git a/packages/back-end/src/api/environments/deleteEnvironment.ts b/packages/back-end/src/api/environments/deleteEnvironment.ts
new file mode 100644
index 00000000000..50bd479f072
--- /dev/null
+++ b/packages/back-end/src/api/environments/deleteEnvironment.ts
@@ -0,0 +1,46 @@
+import { DeleteEnvironmentResponse } from "../../../types/openapi";
+import { createApiRequestHandler } from "../../util/handler";
+import { deleteEnvironmentValidator } from "../../validators/openapi";
+import { updateOrganization } from "../../models/OrganizationModel";
+import { OrganizationInterface } from "../../../types/organization";
+import { auditDetailsDelete } from "../../services/audit";
+
+export const deleteEnvironment = createApiRequestHandler(
+ deleteEnvironmentValidator
+)(
+ async (req): Promise => {
+ const id = req.params.id;
+ const org = req.context.org;
+ const environments = org.settings?.environments || [];
+
+ const environment = environments.find((env) => env.id === id);
+ if (!environment) {
+ throw Error(`Environment ${id} does not exists!`);
+ }
+
+ if (!req.context.permissions.canDeleteEnvironment(environment))
+ req.context.permissions.throwPermissionError();
+
+ const updates: Partial = {
+ settings: {
+ ...org.settings,
+ environments: [...environments.filter((env) => env.id !== id)],
+ },
+ };
+
+ await updateOrganization(org.id, updates);
+
+ await req.audit({
+ event: "environment.delete",
+ entity: {
+ object: "environment",
+ id: environment.id,
+ },
+ details: auditDetailsDelete(environment),
+ });
+
+ return {
+ deletedId: id,
+ };
+ }
+);
diff --git a/packages/back-end/src/api/environments/environments.router.ts b/packages/back-end/src/api/environments/environments.router.ts
new file mode 100644
index 00000000000..a44ef8c4a11
--- /dev/null
+++ b/packages/back-end/src/api/environments/environments.router.ts
@@ -0,0 +1,14 @@
+import { Router } from "express";
+import { listEnvironments } from "./listEnvironments";
+import { putEnvironment } from "./putEnvironment";
+import { postEnvironment } from "./postEnvironment";
+import { deleteEnvironment } from "./deleteEnvironment";
+
+const router = Router();
+
+router.get("/", listEnvironments);
+router.post("/", postEnvironment);
+router.put("/:id", putEnvironment);
+router.delete("/:id", deleteEnvironment);
+
+export default router;
diff --git a/packages/back-end/src/api/environments/listEnvironments.ts b/packages/back-end/src/api/environments/listEnvironments.ts
new file mode 100644
index 00000000000..de07ea61b20
--- /dev/null
+++ b/packages/back-end/src/api/environments/listEnvironments.ts
@@ -0,0 +1,33 @@
+import { ListEnvironmentsResponse } from "../../../types/openapi";
+import { createApiRequestHandler } from "../../util/handler";
+import { listEnvironmentsValidator } from "../../validators/openapi";
+
+export const listEnvironments = createApiRequestHandler(
+ listEnvironmentsValidator
+)(
+ async (req): Promise => {
+ const environments = (
+ req.context.org.settings?.environments || []
+ ).filter((environment) =>
+ req.context.permissions.canReadMultiProjectResource(environment.projects)
+ );
+
+ return {
+ environments: environments.map(
+ ({
+ id,
+ description = "",
+ toggleOnList = false,
+ defaultState = false,
+ projects = [],
+ }) => ({
+ id,
+ projects,
+ description,
+ defaultState,
+ toggleOnList,
+ })
+ ),
+ };
+ }
+);
diff --git a/packages/back-end/src/api/environments/postEnvironment.ts b/packages/back-end/src/api/environments/postEnvironment.ts
new file mode 100644
index 00000000000..042cde7d259
--- /dev/null
+++ b/packages/back-end/src/api/environments/postEnvironment.ts
@@ -0,0 +1,46 @@
+import { PostEnvironmentResponse } from "../../../types/openapi";
+import { createApiRequestHandler } from "../../util/handler";
+import { postEnvironmentValidator } from "../../validators/openapi";
+import { updateOrganization } from "../../models/OrganizationModel";
+import { OrganizationInterface } from "../../../types/organization";
+import { auditDetailsCreate } from "../../services/audit";
+import { validatePayload } from "./validations";
+
+export const postEnvironment = createApiRequestHandler(
+ postEnvironmentValidator
+)(
+ async (req): Promise => {
+ const environment = await validatePayload(req.context, req.body);
+
+ const org = req.context.org;
+
+ if (org.settings?.environments?.some((env) => env.id === environment.id)) {
+ throw Error(`Environment ${environment.id} already exists!`);
+ }
+
+ if (!req.context.permissions.canCreateOrUpdateEnvironment(environment))
+ req.context.permissions.throwPermissionError();
+
+ const updates: Partial = {
+ settings: {
+ ...org.settings,
+ environments: [...(org.settings?.environments || []), environment],
+ },
+ };
+
+ await updateOrganization(org.id, updates);
+
+ await req.audit({
+ event: "environment.create",
+ entity: {
+ object: "environment",
+ id: environment.id,
+ },
+ details: auditDetailsCreate(environment),
+ });
+
+ return {
+ environment,
+ };
+ }
+);
diff --git a/packages/back-end/src/api/environments/putEnvironment.ts b/packages/back-end/src/api/environments/putEnvironment.ts
new file mode 100644
index 00000000000..2868e70080d
--- /dev/null
+++ b/packages/back-end/src/api/environments/putEnvironment.ts
@@ -0,0 +1,54 @@
+import { PutEnvironmentResponse } from "../../../types/openapi";
+import { createApiRequestHandler } from "../../util/handler";
+import { putEnvironmentValidator } from "../../validators/openapi";
+import { updateOrganization } from "../../models/OrganizationModel";
+import { OrganizationInterface } from "../../../types/organization";
+import { auditDetailsUpdate } from "../../services/audit";
+import { validatePayload } from "./validations";
+
+export const putEnvironment = createApiRequestHandler(putEnvironmentValidator)(
+ async (req): Promise => {
+ const id = req.params.id;
+ const org = req.context.org;
+ const environments = org.settings?.environments || [];
+
+ const environment = environments.find((env) => env.id === id);
+ if (!environment) {
+ throw Error(`Environment ${id} does not exists!`);
+ }
+
+ const updatedEnvironment = await validatePayload(req.context, {
+ ...environment,
+ ...req.body,
+ });
+
+ if (
+ !req.context.permissions.canCreateOrUpdateEnvironment(updatedEnvironment)
+ )
+ req.context.permissions.throwPermissionError();
+
+ const updates: Partial = {
+ settings: {
+ ...org.settings,
+ environments: environments.map((env) =>
+ env.id === id ? updatedEnvironment : env
+ ),
+ },
+ };
+
+ await updateOrganization(org.id, updates);
+
+ await req.audit({
+ event: "environment.update",
+ entity: {
+ object: "environment",
+ id: environment.id,
+ },
+ details: auditDetailsUpdate(environment, updatedEnvironment),
+ });
+
+ return {
+ environment: updatedEnvironment,
+ };
+ }
+);
diff --git a/packages/back-end/src/api/environments/validations.ts b/packages/back-end/src/api/environments/validations.ts
new file mode 100644
index 00000000000..3e0e420950d
--- /dev/null
+++ b/packages/back-end/src/api/environments/validations.ts
@@ -0,0 +1,35 @@
+import { findAllProjectsByOrganization } from "../../models/ProjectModel";
+import { ApiReqContext } from "../../../types/api";
+
+export const validatePayload = async (
+ context: ApiReqContext,
+ {
+ id,
+ description = "",
+ projects = [],
+ toggleOnList = false,
+ defaultState = false,
+ }: {
+ id: string;
+ description?: string;
+ projects?: string[];
+ toggleOnList?: boolean;
+ defaultState?: boolean;
+ }
+) => {
+ if (id === "") throw Error("Environment ID cannot empty!");
+
+ if (projects.length) {
+ const allProjects = await findAllProjectsByOrganization(context);
+ const nonexistentProjects = projects.filter(
+ (p) => !allProjects.some(({ id }) => p === id)
+ );
+
+ if (nonexistentProjects.length)
+ throw new Error(
+ `The following projects do not exist: ${nonexistentProjects.join(", ")}`
+ );
+ }
+
+ return { id, projects, description, toggleOnList, defaultState };
+};
diff --git a/packages/back-end/src/api/openapi/openapi.yaml b/packages/back-end/src/api/openapi/openapi.yaml
index fd2ef186285..0be63a1b108 100644
--- a/packages/back-end/src/api/openapi/openapi.yaml
+++ b/packages/back-end/src/api/openapi/openapi.yaml
@@ -60,6 +60,12 @@ tags:
- name: projects
x-displayName: Projects
description: Projects are used to organize your feature flags and experiments
+ - name: environments
+ x-displayName: Environments
+ description: GrowthBook comes with one environment by default (production),
+ but you can add as many as you need. When used with feature
+ flags, you can enable/disable feature flags on a per-environment
+ basis.
- name: features
x-displayName: Feature Flags
description: Control your feature flags programatically
@@ -190,6 +196,16 @@ paths:
/organizations/{id}:
put:
$ref: "./paths/putOrganization.yaml"
+ /environments:
+ get:
+ $ref: "./paths/listEnvironments.yaml"
+ post:
+ $ref: "./paths/postEnvironment.yaml"
+ /environments/${id}:
+ put:
+ $ref: "./paths/putEnvironment.yaml"
+ delete:
+ $ref: "./paths/deleteEnvironment.yaml"
/fact-tables:
get:
$ref: "./paths/listFactTables.yaml"
diff --git a/packages/back-end/src/api/openapi/paths/deleteEnvironment.yaml b/packages/back-end/src/api/openapi/paths/deleteEnvironment.yaml
new file mode 100644
index 00000000000..cd1212df4d6
--- /dev/null
+++ b/packages/back-end/src/api/openapi/paths/deleteEnvironment.yaml
@@ -0,0 +1,22 @@
+summary: Deletes a single environment
+parameters:
+ - $ref: "../parameters.yaml#/id"
+tags:
+ - environments
+operationId: deleteEnvironment
+x-codeSamples:
+ - lang: "cURL"
+ source: |
+ curl -X DELETE https://api.growthbook.io/api/v1/enviromnents/env-id \
+ -u secret_abc123DEF456:
+responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - deletedId
+ properties:
+ deletedId:
+ type: string
diff --git a/packages/back-end/src/api/openapi/paths/listEnvironments.yaml b/packages/back-end/src/api/openapi/paths/listEnvironments.yaml
new file mode 100644
index 00000000000..1e509a06579
--- /dev/null
+++ b/packages/back-end/src/api/openapi/paths/listEnvironments.yaml
@@ -0,0 +1,23 @@
+summary: Get the organization's environments
+tags:
+ - environments
+operationId: listEnvironments
+x-codeSamples:
+ - lang: 'cURL'
+ source: |
+ curl https://api.growthbook.io/api/v1/environments \
+ -u secret_abc123DEF456:
+responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ allOf:
+ - type: object
+ required:
+ - environments
+ properties:
+ environments:
+ type: array
+ items:
+ $ref: "../schemas/Environment.yaml"
diff --git a/packages/back-end/src/api/openapi/paths/postEnvironment.yaml b/packages/back-end/src/api/openapi/paths/postEnvironment.yaml
new file mode 100644
index 00000000000..d7181b551dc
--- /dev/null
+++ b/packages/back-end/src/api/openapi/paths/postEnvironment.yaml
@@ -0,0 +1,46 @@
+tags:
+ - environments
+summary: Create a new environment
+operationId: postEnvironment
+x-codeSamples:
+ - lang: "cURL"
+ source: |
+ curl -X POST https://api.growthbook.io/api/v1/environments \
+ -d '{"id": "new-env", "description": "My new environment" }' \
+ -u secret_abc123DEF456:
+requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - id
+ properties:
+ id:
+ type: string
+ description: The ID of the new environment
+ description:
+ type: string
+ description: The description of the new environment
+ toggleOnList:
+ type: bool
+ description: Show toggle on feature list
+ defaultState:
+ type: bool
+ description: Default state for new features
+ projects:
+ type: array
+ items:
+ type: string
+responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - environment
+ properties:
+ environment:
+ $ref: "../schemas/Environment.yaml"
diff --git a/packages/back-end/src/api/openapi/paths/putEnvironment.yaml b/packages/back-end/src/api/openapi/paths/putEnvironment.yaml
new file mode 100644
index 00000000000..aa504279259
--- /dev/null
+++ b/packages/back-end/src/api/openapi/paths/putEnvironment.yaml
@@ -0,0 +1,43 @@
+parameters:
+ - $ref: "../parameters.yaml#/id"
+tags:
+ - environments
+summary: Update an environment
+operationId: putEnvironment
+x-codeSamples:
+ - lang: "cURL"
+ source: |
+ curl -X PUT https://api.growthbook.io/api/v1/environments/env-id \
+ -d '{ "description": "My updated environment" }' \
+ -u secret_abc123DEF456:
+requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ description:
+ type: string
+ description: The description of the new environment
+ toggleOnList:
+ type: boolean
+ description: Show toggle on feature list
+ defaultState:
+ type: boolean
+ description: Default state for new features
+ projects:
+ type: array
+ items:
+ type: string
+responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - environment
+ properties:
+ environment:
+ $ref: "../schemas/Environment.yaml"
diff --git a/packages/back-end/src/api/openapi/schemas/Environment.yaml b/packages/back-end/src/api/openapi/schemas/Environment.yaml
new file mode 100644
index 00000000000..43c73c647ed
--- /dev/null
+++ b/packages/back-end/src/api/openapi/schemas/Environment.yaml
@@ -0,0 +1,20 @@
+type: object
+required:
+ - id
+ - description
+ - toggleOnList
+ - defaultState
+ - projects
+properties:
+ id:
+ type: string
+ description:
+ type: string
+ toggleOnList:
+ type: boolean
+ defaultState:
+ type: boolean
+ projects:
+ type: array
+ items:
+ type: string
diff --git a/packages/back-end/src/api/openapi/schemas/_index.yaml b/packages/back-end/src/api/openapi/schemas/_index.yaml
index 59eee9904f2..1b2d4f678f6 100644
--- a/packages/back-end/src/api/openapi/schemas/_index.yaml
+++ b/packages/back-end/src/api/openapi/schemas/_index.yaml
@@ -6,6 +6,8 @@ Metric:
$ref: "./Metric.yaml"
Project:
$ref: "./Project.yaml"
+Environment:
+ $ref: "./Environment.yaml"
Segment:
$ref: "./Segment.yaml"
Feature:
diff --git a/packages/back-end/src/app.ts b/packages/back-end/src/app.ts
index e585f64fc9e..b485567c8bc 100644
--- a/packages/back-end/src/app.ts
+++ b/packages/back-end/src/app.ts
@@ -120,7 +120,7 @@ if (SENTRY_DSN) {
);
}
-if (!process.env.NO_INIT) {
+if (!process.env.NO_INIT && process.env.NODE_ENV !== "test") {
init();
}
diff --git a/packages/back-end/src/models/AuditModel.ts b/packages/back-end/src/models/AuditModel.ts
index 2a555ed28f0..e3d28997701 100644
--- a/packages/back-end/src/models/AuditModel.ts
+++ b/packages/back-end/src/models/AuditModel.ts
@@ -46,7 +46,10 @@ const AuditModel = mongoose.model("Audit", auditSchema);
* @param doc
*/
const toInterface = (doc: AuditDocument): AuditInterface => {
- return omit(doc.toJSON(), ["__v", "_id"]);
+ return (omit(doc.toJSON(), [
+ "__v",
+ "_id",
+ ]) as unknown) as AuditInterface;
};
export async function insertAudit(
diff --git a/packages/back-end/src/models/BaseModel.ts b/packages/back-end/src/models/BaseModel.ts
index 000057b4bc4..1f81f4b926c 100644
--- a/packages/back-end/src/models/BaseModel.ts
+++ b/packages/back-end/src/models/BaseModel.ts
@@ -10,8 +10,8 @@ import { ApiReqContext } from "../../types/api";
import { ReqContext } from "../../types/organization";
import { CreateProps, UpdateProps } from "../../types/models";
import { logger } from "../util/logger";
-import { EventType } from "../../types/audit";
-import { EntityType } from "../types/Audit";
+import { EntityType, EventTypes, EventType } from "../types/Audit";
+import { AuditInterfaceTemplate } from "../../types/audit";
import {
auditDetailsCreate,
auditDetailsDelete,
@@ -31,16 +31,18 @@ export const baseSchema = z
export type BaseSchema = typeof baseSchema;
-export interface ModelConfig {
+type AuditLogConfig = {
+ entity: Entity;
+ createEvent: EventTypes;
+ updateEvent: EventTypes;
+ deleteEvent: EventTypes;
+};
+
+export interface ModelConfig {
schema: T;
collectionName: string;
idPrefix?: string;
- auditLog: {
- entity: EntityType;
- createEvent: EventType;
- updateEvent: EventType;
- deleteEvent: EventType;
- };
+ auditLog: AuditLogConfig;
projectScoping: "none" | "single" | "multiple";
globallyUniqueIds?: boolean;
skipDateUpdatedFields?: (keyof z.infer)[];
@@ -59,7 +61,7 @@ export interface ModelConfig {
// We only need to add indexes once at server start-up
const indexesAdded: Set = new Set();
-export abstract class BaseModel {
+export abstract class BaseModel {
protected context: Context;
public constructor(context: Context) {
this.context = context;
@@ -70,8 +72,8 @@ export abstract class BaseModel {
/***************
* Required methods that MUST be overridden by subclasses
***************/
- protected config: ModelConfig;
- protected abstract getConfig(): ModelConfig;
+ protected config: ModelConfig;
+ protected abstract getConfig(): ModelConfig;
protected abstract canRead(doc: z.infer): boolean;
protected abstract canCreate(doc: z.infer): boolean;
protected abstract canUpdate(
@@ -312,7 +314,7 @@ export abstract class BaseModel {
},
event: this.config.auditLog.createEvent,
details: auditDetailsCreate(doc),
- });
+ } as AuditInterfaceTemplate);
} catch (e) {
this.context.logger.error(
e,
@@ -424,7 +426,7 @@ export abstract class BaseModel {
},
event: auditEvent,
details: auditDetailsUpdate(doc, newDoc),
- });
+ } as AuditInterfaceTemplate);
} catch (e) {
this.context.logger.error(
e,
@@ -462,7 +464,7 @@ export abstract class BaseModel {
},
event: this.config.auditLog.deleteEvent,
details: auditDetailsDelete(doc),
- });
+ } as AuditInterfaceTemplate);
} catch (e) {
this.context.logger.error(
e,
@@ -559,10 +561,10 @@ export abstract class BaseModel {
}
}
-export const MakeModelClass = (
- config: ModelConfig
+export const MakeModelClass = (
+ config: ModelConfig
) => {
- abstract class Model extends BaseModel {
+ abstract class Model extends BaseModel {
getConfig() {
return config;
}
diff --git a/packages/back-end/src/services/context.ts b/packages/back-end/src/services/context.ts
index cc48754c1b5..03c4f1864e1 100644
--- a/packages/back-end/src/services/context.ts
+++ b/packages/back-end/src/services/context.ts
@@ -19,7 +19,7 @@ import { FactMetricModel } from "../models/FactMetricModel";
import { ProjectInterface } from "../../types/project";
import { findAllProjectsByOrganization } from "../models/ProjectModel";
import { addTags, getAllTags } from "../models/TagModel";
-import { AuditInterface } from "../../types/audit";
+import { AuditInterfaceInput } from "../../types/audit";
import { insertAudit } from "../models/AuditModel";
import { logger } from "../util/logger";
@@ -150,9 +150,7 @@ export class ReqContextClass {
}
// Record an audit log entry
- public async auditLog(
- data: Omit
- ) {
+ public async auditLog(data: AuditInterfaceInput) {
const auditUser = this.userId
? {
id: this.userId,
diff --git a/packages/back-end/src/types/Audit.ts b/packages/back-end/src/types/Audit.ts
index d189778d210..a7f8968b319 100644
--- a/packages/back-end/src/types/Audit.ts
+++ b/packages/back-end/src/types/Audit.ts
@@ -1,14 +1,55 @@
-export const EntityType = [
- "experiment",
- "feature",
- "metric",
- "datasource",
- "comment",
- "user",
- "organization",
- "savedGroup",
- "archetype",
- "team",
-] as const;
+export const entityEvents = {
+ attribute: ["create", "update", "delete"],
+ experiment: [
+ "create",
+ "update",
+ "start",
+ "phase",
+ "phase",
+ "stop",
+ "status",
+ "archive",
+ "unarchive",
+ "delete",
+ "results",
+ "analysis",
+ "screenshot",
+ "screenshot",
+ "refresh",
+ "launchChecklist.updated",
+ "phase.delete",
+ "screenshot.delete",
+ "screenshot.create",
+ ],
+ environment: ["create", "update", "delete"],
+ feature: [
+ "create",
+ "publish",
+ "revert",
+ "update",
+ "toggle",
+ "archive",
+ "delete",
+ ],
+ metric: ["autocreate", "create", "update", "delete", "analysis"],
+ datasource: ["create", "update", "delete", "import"],
+ comment: ["create", "update", "delete"],
+ user: ["create", "update", "delete", "invite"],
+ organization: ["create", "update", "delete"],
+ savedGroup: ["created", "deleted", "updated"],
+ archetype: ["created", "deleted", "updated"],
+ team: ["create", "delete", "update"],
+} as const;
+export type EntityEvents = typeof entityEvents;
+export const EntityType = Object.keys(entityEvents) as [keyof EntityEvents];
export type EntityType = typeof EntityType[number];
+
+export type EventTypes<
+ k extends EntityType
+> = `${k}.${EntityEvents[k][number]}`;
+export type EventType = EntityType extends unknown
+ ? EntityEvents[EntityType][number] extends unknown
+ ? `${EntityType}.${EntityEvents[EntityType][number]}`
+ : never
+ : never;
diff --git a/packages/back-end/src/validators/openapi.ts b/packages/back-end/src/validators/openapi.ts
index 85cfe3071f2..2a3090b6be3 100644
--- a/packages/back-end/src/validators/openapi.ts
+++ b/packages/back-end/src/validators/openapi.ts
@@ -240,6 +240,30 @@ export const putOrganizationValidator = {
paramsSchema: z.object({ "id": z.string() }).strict(),
};
+export const listEnvironmentsValidator = {
+ bodySchema: z.never(),
+ querySchema: z.never(),
+ paramsSchema: z.never(),
+};
+
+export const postEnvironmentValidator = {
+ bodySchema: z.object({ "id": z.string().describe("The ID of the new environment"), "description": z.string().describe("The description of the new environment").optional(), "toggleOnList": z.any().describe("Show toggle on feature list").optional(), "defaultState": z.any().describe("Default state for new features").optional(), "projects": z.array(z.string()).optional() }).strict(),
+ querySchema: z.never(),
+ paramsSchema: z.never(),
+};
+
+export const putEnvironmentValidator = {
+ bodySchema: z.object({ "description": z.string().describe("The description of the new environment").optional(), "toggleOnList": z.boolean().describe("Show toggle on feature list").optional(), "defaultState": z.boolean().describe("Default state for new features").optional(), "projects": z.array(z.string()).optional() }).strict(),
+ querySchema: z.never(),
+ paramsSchema: z.object({ "id": z.string() }).strict(),
+};
+
+export const deleteEnvironmentValidator = {
+ bodySchema: z.never(),
+ querySchema: z.never(),
+ paramsSchema: z.object({ "id": z.string() }).strict(),
+};
+
export const listFactTablesValidator = {
bodySchema: z.never(),
querySchema: z.object({ "limit": z.coerce.number().int().default(10), "offset": z.coerce.number().int().default(0), "datasourceId": z.string().optional(), "projectId": z.string().optional() }).strict(),
diff --git a/packages/back-end/test/api/api.setup.ts b/packages/back-end/test/api/api.setup.ts
new file mode 100644
index 00000000000..78469918efa
--- /dev/null
+++ b/packages/back-end/test/api/api.setup.ts
@@ -0,0 +1,69 @@
+import mongoose from "mongoose";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import { getAuthConnection } from "../../src/services/auth";
+import authenticateApiRequestMiddleware from "../../src/middleware/authenticateApiRequestMiddleware";
+import app from "../../src/app";
+
+jest.mock("../../src/init/queue", () => ({
+ queueInit: () => undefined,
+}));
+
+jest.mock("../../src/services/auth", () => ({
+ ...jest.requireActual("../../src/services/auth"),
+ getAuthConnection: () => ({
+ middleware: jest.fn(),
+ }),
+}));
+
+jest.mock("../../src/middleware/authenticateApiRequestMiddleware", () => ({
+ ...jest.requireActual(
+ "../../src/middleware/authenticateApiRequestMiddleware"
+ ),
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+export const setupApp = () => {
+ let mongodb;
+ let reqContext;
+ const auditMock = jest.fn();
+ const OLD_ENV = process.env;
+
+ beforeAll(async () => {
+ mongodb = await MongoMemoryServer.create();
+ const uri = mongodb.getUri();
+ process.env.MONGO_URL = uri;
+ getAuthConnection().middleware.mockImplementation((req, res, next) => {
+ next();
+ });
+
+ authenticateApiRequestMiddleware.mockImplementation((req, res, next) => {
+ req.audit = auditMock;
+ req.context = reqContext;
+ next();
+ });
+ });
+
+ afterAll(async () => {
+ await mongoose.connection.close();
+ await mongodb.stop();
+ process.env = OLD_ENV;
+ });
+
+ afterEach(async () => {
+ jest.clearAllMocks();
+ const collections = mongoose.connection.collections;
+ for (const key in collections) {
+ const collection = collections[key];
+ await collection.deleteMany();
+ }
+ });
+
+ return {
+ app,
+ auditMock,
+ setReqContext: (v) => {
+ reqContext = v;
+ },
+ };
+};
diff --git a/packages/back-end/test/api/environments.test.ts b/packages/back-end/test/api/environments.test.ts
new file mode 100644
index 00000000000..adafe943b38
--- /dev/null
+++ b/packages/back-end/test/api/environments.test.ts
@@ -0,0 +1,661 @@
+import request from "supertest";
+import { updateOrganization } from "../../src/models/OrganizationModel";
+import { findAllProjectsByOrganization } from "../../src/models/ProjectModel";
+import { setupApp } from "./api.setup";
+
+jest.mock("../../src/models/ProjectModel", () => ({
+ findAllProjectsByOrganization: jest.fn(),
+}));
+
+jest.mock("../../src/models/OrganizationModel", () => ({
+ updateOrganization: jest.fn(),
+}));
+
+describe("environements API", () => {
+ const { app, auditMock, setReqContext } = setupApp();
+
+ afterEach(async () => {
+ jest.clearAllMocks();
+ });
+
+ it("can list all environments", async () => {
+ setReqContext({
+ org: {
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canReadMultiProjectResource: () => true,
+ },
+ });
+
+ const response = await request(app)
+ .get("/api/v1/environments")
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ description: "",
+ toggleOnList: false,
+ defaultState: false,
+ projects: [],
+ },
+ ],
+ });
+ });
+
+ it("can filter environments", async () => {
+ setReqContext({
+ org: {
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canReadMultiProjectResource: (projects) =>
+ (projects || []).includes("bla"),
+ },
+ });
+
+ const response = await request(app)
+ .get("/api/v1/environments")
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ ],
+ });
+ });
+
+ it("can delete environments", async () => {
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canDeleteEnvironment: () => true,
+ },
+ });
+
+ const response = await request(app)
+ .delete("/api/v1/environments/env1")
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ deletedId: "env1" });
+ expect(updateOrganization).toHaveBeenCalledWith("org1", {
+ settings: { environments: [{ id: "env2" }] },
+ });
+ expect(auditMock).toHaveBeenCalledWith({
+ details:
+ '{"pre":{"id":"env1","description":"env1","toggleOnList":true,"defaultState":true,"projects":["bla"]},"context":{}}',
+ entity: { id: "env1", object: "environment" },
+ event: "environment.delete",
+ });
+ });
+
+ it("checks for permission to delete environments", async () => {
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canDeleteEnvironment: () => false,
+ throwPermissionError: () => {
+ throw new Error("permission error");
+ },
+ },
+ });
+
+ const response = await request(app)
+ .delete("/api/v1/environments/env1")
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ message: "permission error" });
+ expect(updateOrganization).not.toHaveBeenCalledWith();
+ });
+
+ it("can update environments", async () => {
+ findAllProjectsByOrganization.mockReturnValue([
+ { id: "proj1" },
+ { id: "proj2" },
+ { id: "proj3" },
+ ]);
+
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canCreateOrUpdateEnvironment: () => true,
+ },
+ });
+
+ const response = await request(app)
+ .put("/api/v1/environments/env1")
+ .send({
+ description: "new description",
+ toggleOnList: false,
+ defaultState: false,
+ projects: ["proj1", "proj2"],
+ })
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ environment: {
+ defaultState: false,
+ description: "new description",
+ id: "env1",
+ projects: ["proj1", "proj2"],
+ toggleOnList: false,
+ },
+ });
+ expect(updateOrganization).toHaveBeenCalledWith("org1", {
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "new description",
+ toggleOnList: false,
+ defaultState: false,
+ projects: ["proj1", "proj2"],
+ },
+ { id: "env2" },
+ ],
+ },
+ });
+ expect(auditMock).toHaveBeenCalledWith({
+ details:
+ '{"pre":{"id":"env1","description":"env1","toggleOnList":true,"defaultState":true,"projects":["bla"]},"post":{"id":"env1","projects":["proj1","proj2"],"description":"new description","toggleOnList":false,"defaultState":false},"context":{}}',
+ entity: {
+ id: "env1",
+ object: "environment",
+ },
+ event: "environment.update",
+ });
+ });
+
+ it("refuses to update projects when they do not exist", async () => {
+ findAllProjectsByOrganization.mockReturnValue([
+ { id: "proj1" },
+ { id: "proj3" },
+ ]);
+
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canCreateOrUpdateEnvironment: () => true,
+ },
+ });
+
+ const response = await request(app)
+ .put("/api/v1/environments/env1")
+ .send({
+ description: "new description",
+ toggleOnList: false,
+ defaultState: false,
+ projects: ["proj1", "proj2"],
+ })
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({
+ message: "The following projects do not exist: proj2",
+ });
+ expect(updateOrganization).not.toHaveBeenCalledWith();
+ expect(auditMock).not.toHaveBeenCalledWith();
+ });
+
+ it("validates update payload", async () => {
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canCreateOrUpdateEnvironment: () => true,
+ },
+ });
+
+ const response = await request(app)
+ .put("/api/v1/environments/env1")
+ .send({
+ toggleOnList: "Gni",
+ defaultState: false,
+ })
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({
+ message: "Request body: [toggleOnList] Expected boolean, received string",
+ });
+ expect(updateOrganization).not.toHaveBeenCalledWith();
+ expect(auditMock).not.toHaveBeenCalledWith();
+ });
+
+ it("checks for update permission", async () => {
+ findAllProjectsByOrganization.mockReturnValue([{ id: "bla" }]);
+
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canCreateOrUpdateEnvironment: () => false,
+ throwPermissionError: () => {
+ throw new Error("permission error");
+ },
+ },
+ });
+
+ const response = await request(app)
+ .put("/api/v1/environments/env1")
+ .send({
+ toggleOnList: true,
+ defaultState: false,
+ })
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({
+ message: "permission error",
+ });
+ expect(updateOrganization).not.toHaveBeenCalledWith();
+ expect(auditMock).not.toHaveBeenCalledWith();
+ });
+
+ it("can create environments", async () => {
+ findAllProjectsByOrganization.mockReturnValue([
+ { id: "proj1" },
+ { id: "proj2" },
+ { id: "proj3" },
+ ]);
+
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canCreateOrUpdateEnvironment: () => true,
+ },
+ });
+
+ const response = await request(app)
+ .post("/api/v1/environments")
+ .send({
+ id: "env3",
+ description: "new description",
+ toggleOnList: false,
+ defaultState: false,
+ projects: ["proj1", "proj2"],
+ })
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ environment: {
+ defaultState: false,
+ description: "new description",
+ id: "env3",
+ projects: ["proj1", "proj2"],
+ toggleOnList: false,
+ },
+ });
+ expect(updateOrganization).toHaveBeenCalledWith("org1", {
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ {
+ id: "env3",
+ description: "new description",
+ toggleOnList: false,
+ defaultState: false,
+ projects: ["proj1", "proj2"],
+ },
+ ],
+ },
+ });
+ expect(auditMock).toHaveBeenCalledWith({
+ details:
+ '{"post":{"id":"env3","projects":["proj1","proj2"],"description":"new description","toggleOnList":false,"defaultState":false},"context":{}}',
+ entity: {
+ id: "env3",
+ object: "environment",
+ },
+ event: "environment.create",
+ });
+ });
+
+ it("refuses to create with projects that do not exist", async () => {
+ findAllProjectsByOrganization.mockReturnValue([
+ { id: "proj1" },
+ { id: "proj3" },
+ ]);
+
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canCreateOrUpdateEnvironment: () => true,
+ },
+ });
+
+ const response = await request(app)
+ .post("/api/v1/environments")
+ .send({
+ id: "env3",
+ description: "new description",
+ toggleOnList: false,
+ defaultState: false,
+ projects: ["proj1", "proj2"],
+ })
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({
+ message: "The following projects do not exist: proj2",
+ });
+ expect(updateOrganization).not.toHaveBeenCalledWith();
+ expect(auditMock).not.toHaveBeenCalledWith();
+ });
+
+ it("validates create payload", async () => {
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canCreateOrUpdateEnvironment: () => true,
+ },
+ });
+
+ const response = await request(app)
+ .post("/api/v1/environments")
+ .send({
+ toggleOnList: "Gni",
+ defaultState: false,
+ })
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({
+ message: "Request body: [id] Required",
+ });
+ expect(updateOrganization).not.toHaveBeenCalledWith();
+ expect(auditMock).not.toHaveBeenCalledWith();
+ });
+
+ it("checks for create permission", async () => {
+ findAllProjectsByOrganization.mockReturnValue([{ id: "bla" }]);
+
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canCreateOrUpdateEnvironment: () => false,
+ throwPermissionError: () => {
+ throw new Error("permission error");
+ },
+ },
+ });
+
+ const response = await request(app)
+ .post("/api/v1/environments")
+ .send({
+ id: "env3",
+ description: "new env",
+ toggleOnList: true,
+ defaultState: false,
+ })
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({
+ message: "permission error",
+ });
+ expect(updateOrganization).not.toHaveBeenCalledWith();
+ expect(auditMock).not.toHaveBeenCalledWith();
+ });
+
+ it("fails to create environments with an empty ID", async () => {
+ findAllProjectsByOrganization.mockReturnValue([
+ { id: "proj1" },
+ { id: "proj2" },
+ { id: "proj3" },
+ ]);
+
+ setReqContext({
+ org: {
+ id: "org1",
+ settings: {
+ environments: [
+ {
+ id: "env1",
+ description: "env1",
+ toggleOnList: true,
+ defaultState: true,
+ projects: ["bla"],
+ },
+ {
+ id: "env2",
+ },
+ ],
+ },
+ },
+ permissions: {
+ canCreateOrUpdateEnvironment: () => true,
+ },
+ });
+
+ const response = await request(app)
+ .post("/api/v1/environments")
+ .send({
+ id: "",
+ description: "new description",
+ toggleOnList: false,
+ defaultState: false,
+ projects: ["proj1", "proj2"],
+ })
+ .set("Authorization", "Bearer foo");
+
+ expect(response.status).toBe(400);
+ expect(response.body).toEqual({ message: "Environment ID cannot empty!" });
+ expect(updateOrganization).not.toHaveBeenCalled();
+ expect(auditMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/back-end/types/api.d.ts b/packages/back-end/types/api.d.ts
index e6c718f56e6..0911add6c81 100644
--- a/packages/back-end/types/api.d.ts
+++ b/packages/back-end/types/api.d.ts
@@ -4,7 +4,7 @@ import {
} from "@growthbook/growthbook";
import { EventAuditUser } from "../src/events/event-types";
import { PermissionFunctions } from "../src/types/AuthRequest";
-import { AuditInterface } from "./audit";
+import { AuditInterfaceInput } from "./audit";
import { ExperimentStatus } from "./experiment";
import { OrganizationInterface, ReqContext } from "./organization";
import { UserInterface } from "./user";
@@ -49,9 +49,7 @@ export type ApiRequestLocals = PermissionFunctions & {
user?: UserInterface;
organization: OrganizationInterface;
eventAudit: EventAuditUser;
- audit: (
- data: Omit
- ) => Promise;
+ audit: (data: AuditInterfaceInput) => Promise;
context: ApiReqContext;
};
diff --git a/packages/back-end/types/audit.d.ts b/packages/back-end/types/audit.d.ts
index ef8a8352a5e..1d1a0ad5eb9 100644
--- a/packages/back-end/types/audit.d.ts
+++ b/packages/back-end/types/audit.d.ts
@@ -1,60 +1,5 @@
-import { EntityType } from "../src/types/Audit";
-
-export type EventType =
- | "attribute.create"
- | "attribute.update"
- | "attribute.delete"
- | "experiment.create"
- | "experiment.update"
- | "experiment.start"
- | "experiment.phase"
- | "experiment.phase.delete"
- | "experiment.stop"
- | "experiment.status"
- | "experiment.archive"
- | "experiment.unarchive"
- | "experiment.delete"
- | "experiment.results"
- | "experiment.analysis"
- | "experiment.screenshot.create"
- | "experiment.screenshot.delete"
- | "experiment.refresh"
- | "experiment.launchChecklist.updated"
- | "feature.create"
- | "feature.publish"
- | "feature.revert"
- | "feature.update"
- | "feature.toggle"
- | "feature.archive"
- | "feature.delete"
- | "metric.autocreate"
- | "metric.create"
- | "metric.update"
- | "metric.delete"
- | "metric.analysis"
- | "datasource.create"
- | "datasource.update"
- | "datasource.delete"
- | "datasource.import"
- | "comment.create"
- | "comment.update"
- | "comment.delete"
- | "user.create"
- | "user.update"
- | "user.delete"
- | "user.invite"
- | "organization.create"
- | "organization.update"
- | "organization.delete"
- | "savedGroup.created"
- | "savedGroup.deleted"
- | "savedGroup.updated"
- | "archetype.created"
- | "archetype.deleted"
- | "archetype.updated"
- | "team.create"
- | "team.delete"
- | "team.update";
+import { EntityType, EntityEvents } from "../src/types/Audit";
+export { EventType } from "../src/types/Audit";
export interface AuditUserLoggedIn {
id: string;
@@ -66,21 +11,31 @@ export interface AuditUserApiKey {
apiKey: string;
}
-export interface AuditInterface {
- id: string;
- organization: string;
- user: AuditUserLoggedIn | AuditUserApiKey;
- event: EventType;
- entity: {
- object: EntityType;
- id: string;
- name?: string;
- };
- parent?: {
- object: EntityType;
- id: string;
- };
- reason?: string;
- details?: string;
- dateCreated: Date;
-}
+export type AuditInterfaceTemplate = Entity extends EntityType
+ ? {
+ id: string;
+ organization: string;
+ user: AuditUserLoggedIn | AuditUserApiKey;
+ event: `${Entity}.${EntityEvents[Entity][number]}`;
+ entity: {
+ object: Entity;
+ id: string;
+ name?: string;
+ };
+ parent?: {
+ object: Entity;
+ id: string;
+ };
+ reason?: string;
+ details?: string;
+ dateCreated: Date;
+ }
+ : never;
+
+export type AuditInterface = AuditInterfaceTemplate;
+
+export type AuditInterfaceInputTemplate = Interface extends unknown
+ ? Omit
+ : never;
+
+export type AuditInterfaceInput = AuditInterfaceInputTemplate;
diff --git a/packages/back-end/types/openapi.d.ts b/packages/back-end/types/openapi.d.ts
index 9348b4d3e36..d07a2674942 100644
--- a/packages/back-end/types/openapi.d.ts
+++ b/packages/back-end/types/openapi.d.ts
@@ -199,6 +199,18 @@ export interface paths {
/** Edit a single organization (only for super admins on multi-org Enterprise Plan only) */
put: operations["putOrganization"];
};
+ "/environments": {
+ /** Get the organization's environments */
+ get: operations["listEnvironments"];
+ /** Create a new environment */
+ post: operations["postEnvironment"];
+ };
+ "/environments/${id}": {
+ /** Update an environment */
+ put: operations["putEnvironment"];
+ /** Deletes a single environment */
+ delete: operations["deleteEnvironment"];
+ };
"/fact-tables": {
/** Get all fact tables */
get: operations["listFactTables"];
@@ -386,6 +398,13 @@ export interface components {
statsEngine?: string;
};
};
+ Environment: {
+ id: string;
+ description: string;
+ toggleOnList: boolean;
+ defaultState: boolean;
+ projects: (string)[];
+ };
Segment: {
id: string;
owner: string;
@@ -4994,6 +5013,112 @@ export interface operations {
};
};
};
+ listEnvironments: {
+ /** Get the organization's environments */
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ environments: ({
+ id: string;
+ description: string;
+ toggleOnList: boolean;
+ defaultState: boolean;
+ projects: (string)[];
+ })[];
+ };
+ };
+ };
+ };
+ };
+ postEnvironment: {
+ /** Create a new environment */
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @description The ID of the new environment */
+ id: string;
+ /** @description The description of the new environment */
+ description?: string;
+ /** @description Show toggle on feature list */
+ toggleOnList?: any;
+ /** @description Default state for new features */
+ defaultState?: any;
+ projects?: (string)[];
+ };
+ };
+ };
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ environment: {
+ id: string;
+ description: string;
+ toggleOnList: boolean;
+ defaultState: boolean;
+ projects: (string)[];
+ };
+ };
+ };
+ };
+ };
+ };
+ putEnvironment: {
+ /** Update an environment */
+ parameters: {
+ /** @description The id of the requested resource */
+ path: {
+ id: string;
+ };
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @description The description of the new environment */
+ description?: string;
+ /** @description Show toggle on feature list */
+ toggleOnList?: boolean;
+ /** @description Default state for new features */
+ defaultState?: boolean;
+ projects?: (string)[];
+ };
+ };
+ };
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ environment: {
+ id: string;
+ description: string;
+ toggleOnList: boolean;
+ defaultState: boolean;
+ projects: (string)[];
+ };
+ };
+ };
+ };
+ };
+ };
+ deleteEnvironment: {
+ /** Deletes a single environment */
+ parameters: {
+ /** @description The id of the requested resource */
+ path: {
+ id: string;
+ };
+ };
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ deletedId: string;
+ };
+ };
+ };
+ };
+ };
listFactTables: {
/** Get all fact tables */
parameters: {
@@ -6218,6 +6343,7 @@ export type ApiPaginationFields = components["schemas"]["PaginationFields"];
export type ApiDimension = components["schemas"]["Dimension"];
export type ApiMetric = components["schemas"]["Metric"];
export type ApiProject = components["schemas"]["Project"];
+export type ApiEnvironment = components["schemas"]["Environment"];
export type ApiSegment = components["schemas"]["Segment"];
export type ApiFeature = components["schemas"]["Feature"];
export type ApiFeatureEnvironment = components["schemas"]["FeatureEnvironment"];
@@ -6281,6 +6407,10 @@ export type DeleteSavedGroupResponse = operations["deleteSavedGroup"]["responses
export type ListOrganizationsResponse = operations["listOrganizations"]["responses"]["200"]["content"]["application/json"];
export type PostOrganizationResponse = operations["postOrganization"]["responses"]["200"]["content"]["application/json"];
export type PutOrganizationResponse = operations["putOrganization"]["responses"]["200"]["content"]["application/json"];
+export type ListEnvironmentsResponse = operations["listEnvironments"]["responses"]["200"]["content"]["application/json"];
+export type PostEnvironmentResponse = operations["postEnvironment"]["responses"]["200"]["content"]["application/json"];
+export type PutEnvironmentResponse = operations["putEnvironment"]["responses"]["200"]["content"]["application/json"];
+export type DeleteEnvironmentResponse = operations["deleteEnvironment"]["responses"]["200"]["content"]["application/json"];
export type ListFactTablesResponse = operations["listFactTables"]["responses"]["200"]["content"]["application/json"];
export type PostFactTableResponse = operations["postFactTable"]["responses"]["200"]["content"]["application/json"];
export type GetFactTableResponse = operations["getFactTable"]["responses"]["200"]["content"]["application/json"];
diff --git a/yarn.lock b/yarn.lock
index c633412ef55..88ee8353c1b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8479,7 +8479,7 @@ arrify@^2.0.0, arrify@^2.0.1:
resolved "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz"
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
-asap@^2.0.3:
+asap@^2.0.0, asap@^2.0.3:
version "2.0.6"
resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz"
integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=
@@ -8542,6 +8542,13 @@ async-lock@^1.4.0:
resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.0.tgz#c8b6630eff68fbbdd8a5b6eb763dac3bfbb8bf02"
integrity sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==
+async-mutex@^0.4.0:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.4.1.tgz#bccf55b96f2baf8df90ed798cb5544a1f6ee4c2c"
+ integrity sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==
+ dependencies:
+ tslib "^2.4.0"
+
async-retry@^1.3.3:
version "1.3.3"
resolved "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz"
@@ -8658,6 +8665,11 @@ axobject-query@^3.2.1:
dependencies:
dequal "^2.0.3"
+b4a@^1.6.4:
+ version "1.6.6"
+ resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.6.tgz#a4cc349a3851987c3c4ac2d7785c18744f6da9ba"
+ integrity sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==
+
babel-jest@^27.5.1:
version "27.5.1"
resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz"
@@ -8818,6 +8830,11 @@ balanced-match@^1.0.0:
resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+bare-events@^2.2.0:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.2.2.tgz#a98a41841f98b2efe7ecc5c5468814469b018078"
+ integrity sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ==
+
base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
@@ -9030,11 +9047,21 @@ bson@^4.7.2:
dependencies:
buffer "^5.6.0"
+bson@^5.5.0:
+ version "5.5.1"
+ resolved "https://registry.yarnpkg.com/bson/-/bson-5.5.1.tgz#f5849d405711a7f23acdda9a442375df858e6833"
+ integrity sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==
+
btoa-lite@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
integrity sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==
+buffer-crc32@~0.2.3:
+ version "0.2.13"
+ resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+ integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
+
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz"
@@ -9589,6 +9616,11 @@ commondir@^1.0.1:
resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz"
integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
+component-emitter@^1.3.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17"
+ integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==
+
compressible@^2.0.12, compressible@~2.0.16:
version "2.0.18"
resolved "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz"
@@ -9702,6 +9734,11 @@ cookie@^0.4.1:
resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
+cookiejar@^2.1.4:
+ version "2.1.4"
+ resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b"
+ integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==
+
core-js-compat@^3.25.1:
version "3.26.0"
resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.0.tgz"
@@ -10251,6 +10288,14 @@ devlop@^1.0.0, devlop@^1.1.0:
dependencies:
dequal "^2.0.0"
+dezalgo@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81"
+ integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==
+ dependencies:
+ asap "^2.0.0"
+ wrappy "1"
+
diff-match-patch@^1.0.5:
version "1.0.5"
resolved "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz"
@@ -11300,6 +11345,11 @@ fast-equals@^2.0.3:
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.4.tgz#3add9410585e2d7364c2deeb6a707beadb24b927"
integrity sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==
+fast-fifo@^1.1.0, fast-fifo@^1.2.0:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c"
+ integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==
+
fast-glob@^3.2.11, fast-glob@^3.2.12, fast-glob@^3.2.5, fast-glob@^3.2.9:
version "3.2.12"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz"
@@ -11480,6 +11530,15 @@ finalhandler@1.2.0:
statuses "2.0.1"
unpipe "~1.0.0"
+find-cache-dir@^3.3.2:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b"
+ integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==
+ dependencies:
+ commondir "^1.0.1"
+ make-dir "^3.0.2"
+ pkg-dir "^4.1.0"
+
find-replace@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
@@ -11645,6 +11704,15 @@ format@^0.2.0:
resolved "https://registry.npmjs.org/format/-/format-0.2.2.tgz"
integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==
+formidable@^3.5.1:
+ version "3.5.1"
+ resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.1.tgz#9360a23a656f261207868b1484624c4c8d06ee1a"
+ integrity sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og==
+ dependencies:
+ dezalgo "^1.0.4"
+ hexoid "^1.0.0"
+ once "^1.4.0"
+
forwarded@0.2.0:
version "0.2.0"
resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"
@@ -12440,6 +12508,11 @@ help-me@^4.0.1:
glob "^8.0.0"
readable-stream "^3.6.0"
+hexoid@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18"
+ integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==
+
highlight.js@11.1.0:
version "11.1.0"
resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-11.1.0.tgz"
@@ -12570,7 +12643,7 @@ https-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
-https-proxy-agent@^7.0.1:
+https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.4:
version "7.0.4"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz#8e97b841a029ad8ddc8731f26595bad868cb4168"
integrity sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==
@@ -14606,7 +14679,7 @@ make-dir@^2.1.0:
pify "^4.0.1"
semver "^5.6.0"
-make-dir@^3.0.0:
+make-dir@^3.0.0, make-dir@^3.0.2:
version "3.1.0"
resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz"
integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
@@ -14921,7 +14994,7 @@ merge2@^1.3.0, merge2@^1.4.1:
resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
-methods@~1.1.2:
+methods@^1.1.2, methods@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
@@ -15224,6 +15297,11 @@ mime@1.6.0:
resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz"
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
+mime@2.6.0:
+ version "2.6.0"
+ resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
+ integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
+
mime@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz"
@@ -15366,6 +15444,32 @@ mongodb-connection-string-url@^2.6.0:
"@types/whatwg-url" "^8.2.1"
whatwg-url "^11.0.0"
+mongodb-memory-server-core@9.2.0:
+ version "9.2.0"
+ resolved "https://registry.yarnpkg.com/mongodb-memory-server-core/-/mongodb-memory-server-core-9.2.0.tgz#d83e52633df910d8a48c1ff27de139fd4ab7f2d9"
+ integrity sha512-9SWZEy+dGj5Fvm5RY/mtqHZKS64o4heDwReD4SsfR7+uNgtYo+JN41kPCcJeIH3aJf04j25i5Dia2s52KmsMPA==
+ dependencies:
+ async-mutex "^0.4.0"
+ camelcase "^6.3.0"
+ debug "^4.3.4"
+ find-cache-dir "^3.3.2"
+ follow-redirects "^1.15.6"
+ https-proxy-agent "^7.0.4"
+ mongodb "^5.9.1"
+ new-find-package-json "^2.0.0"
+ semver "^7.6.0"
+ tar-stream "^3.1.7"
+ tslib "^2.6.2"
+ yauzl "^3.1.3"
+
+mongodb-memory-server@^9.2.0:
+ version "9.2.0"
+ resolved "https://registry.yarnpkg.com/mongodb-memory-server/-/mongodb-memory-server-9.2.0.tgz#9fe8a0128f5b7409041895ce9e56ed28b2cf337d"
+ integrity sha512-w/usKdYtby5EALERxmA0+et+D0brP0InH3a26shNDgGefXA61hgl6U0P3IfwqZlEGRZdkbZig3n57AHZgDiwvg==
+ dependencies:
+ mongodb-memory-server-core "9.2.0"
+ tslib "^2.6.2"
+
mongodb@4.17.2, mongodb@^4.17.2:
version "4.17.2"
resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-4.17.2.tgz#237c0534e36a3449bd74c6bf6d32f87a1ca7200c"
@@ -15390,6 +15494,17 @@ mongodb@^4.11.0:
"@aws-sdk/credential-providers" "^3.186.0"
saslprep "^1.0.3"
+mongodb@^5.9.1:
+ version "5.9.2"
+ resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-5.9.2.tgz#39a73b9fbc87ac9d9c1aaf8aab5c5bb69e2b913e"
+ integrity sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==
+ dependencies:
+ bson "^5.5.0"
+ mongodb-connection-string-url "^2.6.0"
+ socks "^2.7.1"
+ optionalDependencies:
+ "@mongodb-js/saslprep" "^1.1.0"
+
mongoose@^6.11.6:
version "6.12.7"
resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-6.12.7.tgz#97adb534424b2a87a440a592913aae1c12068fc4"
@@ -15528,6 +15643,13 @@ netmask@^2.0.2:
resolved "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz"
integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==
+new-find-package-json@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/new-find-package-json/-/new-find-package-json-2.0.0.tgz#96553638781db35061f351e8ccb4d07126b6407d"
+ integrity sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==
+ dependencies:
+ debug "^4.3.4"
+
next@^14.1.0:
version "14.1.0"
resolved "https://registry.yarnpkg.com/next/-/next-14.1.0.tgz#b31c0261ff9caa6b4a17c5af019ed77387174b69"
@@ -16414,6 +16536,11 @@ path-type@^4.0.0:
resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
+pend@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+ integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
+
pg-cloudflare@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98"
@@ -16591,7 +16718,7 @@ pirates@^4.0.6:
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9"
integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==
-pkg-dir@^4.2.0:
+pkg-dir@^4.1.0, pkg-dir@^4.2.0:
version "4.2.0"
resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz"
integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
@@ -16950,6 +17077,11 @@ queue-microtask@^1.2.2:
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+queue-tick@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.1.tgz#f6f07ac82c1fd60f82e098b417a80e52f1f4c142"
+ integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==
+
quick-format-unescaped@^4.0.3:
version "4.0.4"
resolved "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz"
@@ -18017,6 +18149,11 @@ semver@^7.5.3, semver@^7.5.4:
dependencies:
lru-cache "^6.0.0"
+semver@^7.6.0:
+ version "7.6.2"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13"
+ integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==
+
semver@~7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz"
@@ -18537,6 +18674,16 @@ streamsearch@^1.1.0:
resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
+streamx@^2.15.0:
+ version "2.16.1"
+ resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.16.1.tgz#2b311bd34832f08aa6bb4d6a80297c9caef89614"
+ integrity sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==
+ dependencies:
+ fast-fifo "^1.1.0"
+ queue-tick "^1.0.1"
+ optionalDependencies:
+ bare-events "^2.2.0"
+
strict-uri-encode@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz"
@@ -18846,6 +18993,29 @@ stylis@4.0.13:
resolved "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz"
integrity sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==
+superagent@^9.0.1:
+ version "9.0.2"
+ resolved "https://registry.yarnpkg.com/superagent/-/superagent-9.0.2.tgz#a18799473fc57557289d6b63960610e358bdebc1"
+ integrity sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==
+ dependencies:
+ component-emitter "^1.3.0"
+ cookiejar "^2.1.4"
+ debug "^4.3.4"
+ fast-safe-stringify "^2.1.1"
+ form-data "^4.0.0"
+ formidable "^3.5.1"
+ methods "^1.1.2"
+ mime "2.6.0"
+ qs "^6.11.0"
+
+supertest@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/supertest/-/supertest-7.0.0.tgz#cac53b3d6872a0b317980b2b0cfa820f09cd7634"
+ integrity sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==
+ dependencies:
+ methods "^1.1.2"
+ superagent "^9.0.1"
+
supports-color@^5.3.0, supports-color@^5.5.0:
version "5.5.0"
resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz"
@@ -18943,6 +19113,15 @@ tapable@^2.2.0:
resolved "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz"
integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==
+tar-stream@^3.1.7:
+ version "3.1.7"
+ resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b"
+ integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==
+ dependencies:
+ b4a "^1.6.4"
+ fast-fifo "^1.2.0"
+ streamx "^2.15.0"
+
tarn@^3.0.1, tarn@^3.0.2:
version "3.0.2"
resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz"
@@ -20448,6 +20627,14 @@ yarn@^1.22.18:
resolved "https://registry.npmjs.org/yarn/-/yarn-1.22.19.tgz"
integrity sha512-/0V5q0WbslqnwP91tirOvldvYISzaqhClxzyUKXYxs07yUILIs5jx/k6CFe8bvKSkds5w+eiOqta39Wk3WxdcQ==
+yauzl@^3.1.3:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-3.1.3.tgz#f61c17ad1a09403bc7adb01dfb302a9e74bf4a50"
+ integrity sha512-JCCdmlJJWv7L0q/KylOekyRaUrdEoUxWkWVcgorosTROCFWiS9p2NNPE9Yb91ak7b1N5SxAZEliWpspbZccivw==
+ dependencies:
+ buffer-crc32 "~0.2.3"
+ pend "~1.2.0"
+
yn@3.1.1:
version "3.1.1"
resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz"