Skip to content

Commit

Permalink
PER-9718 [back-end] Endpoint for admins to create environment configu…
Browse files Browse the repository at this point in the history
…ration

New endpoint to create a feature-flag. There are validations on the name and description + the name of the flag has to be unique.

Unit and functional tests are covering all scenarios
  • Loading branch information
iuliandanea committed Oct 22, 2024
1 parent ed5dacd commit d2537d4
Show file tree
Hide file tree
Showing 8 changed files with 399 additions and 1 deletion.
27 changes: 26 additions & 1 deletion packages/api/src/feature_flag/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import type { Request, Response, NextFunction } from "express";

import { logger } from "@stela/logger";
import { featureService } from "./service";
import { extractUserIsAdminFromAuthToken } from "../middleware";
import { createFeatureService } from "./service/create";
import {
extractUserIsAdminFromAuthToken,
verifyAdminAuthentication,
} from "../middleware";
import { validateCreateFeatureFlagRequest } from "./validators";
import { validateIsAdminFromAuthentication } from "../validators/shared";
import { isValidationError } from "../validators/validator_util";

Expand All @@ -27,3 +32,23 @@ featureController.get(
}
}
);

featureController.post(
"/",
verifyAdminAuthentication,
async (req: Request, res: Response, next: NextFunction) => {
try {
validateCreateFeatureFlagRequest(req.body);
const insertedFeatureFlag = await createFeatureService.createFeatureFlag(
req.body
);
res.status(200).send({ data: insertedFeatureFlag });
} catch (err) {
if (isValidationError(err)) {
res.status(400).json({ error: err.message });
return;
}
next(err);
}
}
);
7 changes: 7 additions & 0 deletions packages/api/src/feature_flag/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ export interface FeatureFlagNameRow {
name: string;
}

export interface CreateFeatureFlagRequest {
emailFromAuthToken: string;
adminSubjectFromAuthToken: string;
name: string;
description: string;
}

export interface FeatureFlagRequest {
admin: boolean;
}
19 changes: 19 additions & 0 deletions packages/api/src/feature_flag/queries/create_feature_flag.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
INSERT INTO feature_flag (
name,
description,
globally_enabled,
created_at,
updated_at
) VALUES (
:name,
:description,
:globally_enabled,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
) RETURNING
id,
name,
description,
globally_enabled AS "globallyEnabled",
created_at AS "createdAt",
updated_at AS "updatedAt";
11 changes: 11 additions & 0 deletions packages/api/src/feature_flag/queries/get_feature_flag.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT
feature_flag.id,
feature_flag.name,
feature_flag.description,
feature_flag.globally_enabled AS "globallyEnabled",
feature_flag.created_at AS "createdAt",
feature_flag.updated_at AS "updatedAt"
FROM
feature_flag
WHERE
feature_flag.name = :name
189 changes: 189 additions & 0 deletions packages/api/src/feature_flag/service/create.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import request from "supertest";
import createError from "http-errors";
import type { NextFunction, Request } from "express";
import { logger } from "@stela/logger";
import { db } from "../../database";
import { app } from "../../app";
import { verifyAdminAuthentication } from "../../middleware";
import type { CreateFeatureFlagRequest } from "../models";

jest.mock("../../middleware/authentication");
jest.mock("../../database");
jest.mock("@stela/logger");

const loadFixtures = async (): Promise<void> => {
await db.sql("fixtures.create_test_feature_flags");
};

const clearDatabase = async (): Promise<void> => {
await db.query("TRUNCATE feature_flag CASCADE");
};

describe("POST /feature-flag", () => {
const agent = request(app);
beforeEach(async () => {
(verifyAdminAuthentication as jest.Mock).mockImplementation(
(req: Request, __, next: NextFunction) => {
(req.body as CreateFeatureFlagRequest).emailFromAuthToken =
"test@permanent.org";
(req.body as CreateFeatureFlagRequest).adminSubjectFromAuthToken =
"6b640c73-4963-47de-a096-4a05ff8dc5f5";
next();
}
);
jest.restoreAllMocks();
jest.clearAllMocks();
await loadFixtures();
await clearDatabase();
});

afterEach(async () => {
jest.restoreAllMocks();
jest.clearAllMocks();
await clearDatabase();
});

test("should respond with a 200 status code", async () => {
await agent
.post("/api/v2/feature-flags")
.send({
name: "TEST",
description: "description",
})
.expect(200);
});

test("should respond with a 400 if feature flag already exists", async () => {
await agent
.post("/api/v2/feature-flags")
.send({
name: "TEST",
description: "description",
})
.expect(200);

// trying to create again the same feature flag should fail
await agent
.post("/api/v2/feature-flags")
.send({
name: "TEST",
description: "description",
})
.expect(400);
});

test("should respond with 401 status code if lacking admin authentication", async () => {
(verifyAdminAuthentication as jest.Mock).mockImplementation(
(_: Request, __, next: NextFunction) => {
next(new createError.Unauthorized("You aren't logged in"));
}
);
await agent.post("/api/v2/feature-flags").expect(401);
});

test("should respond with 400 status code if missing emailFromAuthToken", async () => {
(verifyAdminAuthentication as jest.Mock).mockImplementation(
(req: Request, __, next: NextFunction) => {
(req.body as CreateFeatureFlagRequest).adminSubjectFromAuthToken =
"6b640c73-4963-47de-a096-4a05ff8dc5f5";
next();
}
);
await agent
.post("/api/v2/feature-flags")
.send({
name: "TEST",
description: "description",
})
.expect(400);
});

test("should respond with 400 status code if emailFromAuthToken is not a string", async () => {
(verifyAdminAuthentication as jest.Mock).mockImplementation(
(req: Request, __, next: NextFunction) => {
(req.body as { emailFromAuthToken: number }).emailFromAuthToken = 123;
(req.body as CreateFeatureFlagRequest).adminSubjectFromAuthToken =
"6b640c73-4963-47de-a096-4a05ff8dc5f5";
next();
}
);
await agent
.post("/api/v2/feature-flags")
.send({
name: "TEST",
description: "description",
})
.expect(400);
});

test("should respond with 400 status name if code is missing", async () => {
await agent
.post("/api/v2/feature-flags")
.send({
description: "description",
})
.expect(400);
});

test("should respond with 400 status code if name is not a string", async () => {
await agent
.post("/api/v2/feature-flags")
.send({
name: 123,
description: "description",
})
.expect(400);
});

test("should store the new feature flag in the database", async () => {
await agent
.post("/api/v2/feature-flags")
.send({
name: "name",
description: "description",
})
.expect(200);
const result = await db.query(
`SELECT
name,
description,
globally_enabled::boolean as "globallyEnabled"
FROM
feature_flag
WHERE
name = 'name'`
);
expect(result.rows.length).toBe(1);
expect(result.rows[0]).toEqual({
name: "name",
description: "description",
globallyEnabled: true,
});
});

test("should respond with 500 if the database call fails", async () => {
jest.spyOn(db, "sql").mockImplementation(() => {
throw new Error("SQL error");
});
await agent
.post("/api/v2/feature-flags")
.send({
name: "name",
description: "description",
})
.expect(500);
});

test("should log the error if the database call fails", async () => {
const testError = new Error("SQL error");
jest.spyOn(db, "sql").mockRejectedValueOnce(testError);
await agent
.post("/api/v2/feature-flags")
.send({
name: "name",
description: "description",
})
.expect(500);
expect(logger.error).toHaveBeenCalled();
});
});
50 changes: 50 additions & 0 deletions packages/api/src/feature_flag/service/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { logger } from "@stela/logger";
import createError from "http-errors";
import type { CreateFeatureFlagRequest, FeatureFlagRow } from "../models";
import { db } from "../../database";

export const createFeatureFlag = async (
featureFlagData: CreateFeatureFlagRequest
): Promise<FeatureFlagRow> => {
const featureFlagsWithName = async (name: string): Promise<boolean> => {
const result = await db
.sql<FeatureFlagRow>("feature_flag.queries.get_feature_flag", {
name,
})
.catch((err) => {
logger.error(err);
throw new createError.InternalServerError(
"Failed to retrieve feature flags"
);
});

return result.rows.length > 0;
};

if (await featureFlagsWithName(featureFlagData.name)) {
throw new createError.BadRequest("Feature flag already exists");
}

const result = await db
.sql<FeatureFlagRow>("feature_flag.queries.create_feature_flag", {
name: featureFlagData.name,
description: featureFlagData.description,
globally_enabled: true,
})
.catch((err) => {
logger.error(err);
throw new createError.InternalServerError(
"Failed to create feature flag"
);
});

if (result.rows[0] === undefined) {
throw new createError.InternalServerError("Failed to create feature flag");
}

return result.rows[0];
};

export const createFeatureService = {
createFeatureFlag,
};
Loading

0 comments on commit d2537d4

Please sign in to comment.