Skip to content

Commit

Permalink
Merge pull request #139 from PermanentOrg/PER-9866
Browse files Browse the repository at this point in the history
PER-9866 [back-end] Endpoint for admins to update environment configu…
  • Loading branch information
iulianvsp authored Oct 30, 2024
2 parents d78b2b5 + 1d086a1 commit 5ad4afa
Show file tree
Hide file tree
Showing 8 changed files with 413 additions and 6 deletions.
1 change: 0 additions & 1 deletion packages/api/docs/present/paths/feature_flags.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ feature-flags/{id}:
schema:
type: object
required:
- description
- globallyEnabled
properties:
description:
Expand Down
178 changes: 177 additions & 1 deletion packages/api/src/feature_flag/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
extractUserIsAdminFromAuthToken,
verifyAdminAuthentication,
} from "../middleware";
import type { CreateFeatureFlagRequest, FeatureFlagRequest } from "./models";
import type {
CreateFeatureFlagRequest,
UpdateFeatureFlagRequest,
FeatureFlagRequest,
} from "./models";

jest.mock("../database");
jest.mock("../middleware");
Expand All @@ -22,6 +26,8 @@ const clearDatabase = async (): Promise<void> => {
await db.query("TRUNCATE feature_flag CASCADE");
};

const notExistingFeatureFlagId = "1bdf2da6-026b-4e8e-9b57-a86b1817be4d";

describe("GET /feature-flags", () => {
const agent = request(app);

Expand Down Expand Up @@ -283,3 +289,173 @@ describe("POST /feature-flag", () => {
expect(logger.error).toHaveBeenCalled();
});
});

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

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

test("should respond with a 200 status code", async () => {
await agent
.put("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d")
.send({
description: "a description",
globallyEnabled: false,
})
.expect(200);
});

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
.put("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d")
.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 UpdateFeatureFlagRequest).adminSubjectFromAuthToken =
"6b640c73-4963-47de-a096-4a05ff8dc5f5";
next();
}
);
await agent
.put("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d")
.send({
description: "description",
globallyEnabled: true,
})
.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 UpdateFeatureFlagRequest).adminSubjectFromAuthToken =
"6b640c73-4963-47de-a096-4a05ff8dc5f5";
next();
}
);
await agent
.put("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d")
.send({
description: "description",
globallyEnabled: true,
})
.expect(400);
});

test("should respond with 400 status if globallyEnabled is missing", async () => {
await agent
.put("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d")
.send({
description: "description",
})
.expect(400);
});

test("should respond with 400 status code if globallyEnabled is not a boolean", async () => {
await agent
.put("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d")
.send({
description: "description",
globallyEnabled: "a string",
})
.expect(400);
});

test("should respond with 400 status code if description is not a text", async () => {
await agent
.put("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d")
.send({
description: 1,
globallyEnabled: true,
})
.expect(400);
});

test("should update the feature flag in the database", async () => {
await agent
.put("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d")
.send({
description: "a description",
globallyEnabled: false,
})
.expect(200);
const result = await db.query(
`SELECT
description,
globally_enabled::boolean as "globallyEnabled"
FROM
feature_flag
WHERE
id = '1bdf2da6-026b-4e8e-9b57-a86b1817be5d'`
);
expect(result.rows.length).toBe(1);
expect(result.rows[0]).toEqual({
description: "a description",
globallyEnabled: false,
});
});

test("should respond with 500 if the database call fails", async () => {
jest.spyOn(db, "sql").mockImplementation(() => {
throw new Error("SQL error");
});
await agent
.put("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d")
.send({
description: "description",
globallyEnabled: true,
})
.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
.put("/api/v2/feature-flags/1bdf2da6-026b-4e8e-9b57-a86b1817be5d")
.send({
description: "description",
globallyEnabled: true,
})
.expect(500);
expect(logger.error).toHaveBeenCalled();
});

test("should respond with 404 status code if feature flag does not exist", async () => {
await agent
.put(`/api/v2/feature-flags/${notExistingFeatureFlagId}`)
.send({
description: "description",
globallyEnabled: true,
})
.expect(404);
});
});
31 changes: 29 additions & 2 deletions packages/api/src/feature_flag/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import type { Request, Response, NextFunction } from "express";
import { logger } from "@stela/logger";
import { featureService } from "./service";
import { createFeatureService } from "./service/create";
import { updateFeatureService } from "./service/update";
import {
extractUserIsAdminFromAuthToken,
verifyAdminAuthentication,
} from "../middleware";
import { validateCreateFeatureFlagRequest } from "./validators";
import { validateIsAdminFromAuthentication } from "../validators/shared";
import { isValidationError } from "../validators/validator_util";
import {
validateCreateFeatureFlagRequest,
validateUpdateFeatureFlagRequest,
validateFeatureFlagParams,
} from "./validators";
import { validateIsAdminFromAuthentication } from "../validators/shared";

export const featureController = Router();

Expand Down Expand Up @@ -52,3 +57,25 @@ featureController.post(
}
}
);

featureController.put(
"/:featureId",
verifyAdminAuthentication,
async (req: Request, res: Response, next: NextFunction) => {
try {
validateUpdateFeatureFlagRequest(req.body);
validateFeatureFlagParams(req.params);
const featureFlag = await updateFeatureService.updateFeatureFlag(
req.params.featureId,
req.body
);
res.status(200).send({ data: featureFlag });
} 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 @@ -21,3 +21,10 @@ export interface CreateFeatureFlagRequest {
export interface FeatureFlagRequest {
admin: boolean;
}

export interface UpdateFeatureFlagRequest {
emailFromAuthToken: string;
adminSubjectFromAuthToken: string;
description: string;
globallyEnabled: boolean;
}
14 changes: 14 additions & 0 deletions packages/api/src/feature_flag/queries/update_feature_flag.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
UPDATE feature_flag
SET
description = :description,
globally_enabled = :globally_enabled,
updated_at = CURRENT_TIMESTAMP
WHERE (
id = :id
) RETURNING
id,
name,
description,
globally_enabled AS "globallyEnabled",
created_at AS "createdAt",
updated_at AS "updatedAt";
32 changes: 32 additions & 0 deletions packages/api/src/feature_flag/service/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { logger } from "@stela/logger";
import createError from "http-errors";
import type { FeatureFlagRow, UpdateFeatureFlagRequest } from "../models";
import { db } from "../../database";

export const updateFeatureFlag = async (
featureFlagId: string,
featureFlagData: UpdateFeatureFlagRequest
): Promise<FeatureFlagRow> => {
const result = await db
.sql<FeatureFlagRow>("feature_flag.queries.update_feature_flag", {
id: featureFlagId,
description: featureFlagData.description,
globally_enabled: featureFlagData.globallyEnabled,
})
.catch((err) => {
logger.error(err);
throw new createError.InternalServerError(
"Failed to update feature flag"
);
});

if (result.rows[0] === undefined) {
throw new createError.NotFound("Feature flag not found");
}

return result.rows[0];
};

export const updateFeatureService = {
updateFeatureFlag,
};
Loading

0 comments on commit 5ad4afa

Please sign in to comment.