diff --git a/src/errors/codes.ts b/src/errors/codes.ts index fbbeb6d..7a84f1d 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -244,10 +244,7 @@ export const ErrorCode = { UNSUPPORTED_MEDIA_TYPE: "UNSUPPORTED_MEDIA_TYPE", /** Request is syntactically correct but semantically invalid */ - UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY", - - /** Usage aggregate not found for the given developer */ - USAGE_AGGREGATE_NOT_FOUND: "USAGE_AGGREGATE_NOT_FOUND" + UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY" } as const; diff --git a/src/routes/admin.ts b/src/routes/admin.ts index a692bb9..7d591c9 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -15,6 +15,7 @@ import { } from '../services/quotaService.js'; import { createAdminWebhooksRouter } from './admin/webhooks.js'; import { createAdminApisRouter } from './admin/apis.js'; +import { createMaintenanceBannerRouter } from './admin/maintenance/banner.js'; const TRUST_PROXY = process.env.TRUST_PROXY_HEADERS === 'true'; const usageStore: UsageAdminStore = createUsageStore(); @@ -211,4 +212,9 @@ router.use('/webhooks', createAdminWebhooksRouter()); // --------------------------------------------------------------------------- router.use('/apis', createAdminApisRouter()); +// Admin maintenance banner management +// Mounts: POST /api/admin/maintenance/banner +// --------------------------------------------------------------------------- +router.use('/maintenance/banner', createMaintenanceBannerRouter()); + export default router; \ No newline at end of file diff --git a/src/routes/admin/maintenance/banner.test.ts b/src/routes/admin/maintenance/banner.test.ts new file mode 100644 index 0000000..a076d11 --- /dev/null +++ b/src/routes/admin/maintenance/banner.test.ts @@ -0,0 +1,60 @@ +import express from "express"; +import supertest from "supertest"; +import { createMaintenanceBannerRouter } from "./banner.js"; + +describe("Admin Maintenance Banner Endpoint", () => { + let app: express.Express; + + beforeAll(() => { + app = express(); + app.use(express.json()); + + // Mock administrative auth middleware to inject required res.locals context + app.use((req, res, next) => { + res.locals.adminActor = { id: "admin_test_user", role: "superadmin" }; + next(); + }); + + // Mount the sub-router under the exact target path for isolated integration testing + app.use("/api/admin/maintenance/banner", createMaintenanceBannerRouter()); + }); + + // ── SUCCESSFUL CASE ────────────────────────────────────────────────────────── + it("should successfully set the maintenance banner and return 200", async () => { + const response = await supertest(app) + .post("/api/admin/maintenance/banner") + .send({ + message: "System upgrade in progress", + isActive: true, + }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("data"); + expect(response.body.data.message).toBe("System upgrade in progress"); + expect(response.body.data.isActive).toBe(true); + expect(response.body.data).toHaveProperty("updatedAt"); + }); + + // ── (INPUT VALIDATION EDGE CASES) ────────────────────────────────────── + it("should return 400 BadRequest if message is missing or empty", async () => { + const response = await supertest(app) + .post("/api/admin/maintenance/banner") + .send({ + message: " ", + isActive: true, + }); + + expect(response.status).toBe(400); + }); + + it("should return 400 BadRequest if isActive is not a boolean", async () => { + const response = await supertest(app) + .post("/api/admin/maintenance/banner") + .send({ + message: "Valid message", + isActive: "true", + }); + + expect(response.status).toBe(400); + }); +}); \ No newline at end of file diff --git a/src/routes/admin/maintenance/banner.ts b/src/routes/admin/maintenance/banner.ts new file mode 100644 index 0000000..3529e5c --- /dev/null +++ b/src/routes/admin/maintenance/banner.ts @@ -0,0 +1,82 @@ +/** + * Admin API maintenance banner routes. + * * Routes: + * POST /api/admin/maintenance/banner — set or update the maintenance banner + */ + +import { Router } from "express"; +import { getClientIp } from "../../../lib/clientIp.js"; +import { + BadRequestError, + AppError, + InternalServerError, +} from "../../../errors/index.js"; +import { logger } from "../../../logger.js"; + +const TRUST_PROXY = process.env.TRUST_PROXY_HEADERS === "true"; + +export interface MaintenanceBannerRouterDeps { + +} + +/** + * Factory that returns the admin maintenance banner sub-router. + */ +export function createMaintenanceBannerRouter( + _deps: MaintenanceBannerRouterDeps = {}, +): Router { + const router = Router(); + + // ── POST /api/admin/maintenance/banner ────────────────────────────────── + /** + * Set or update the system-wide maintenance banner. + * * Returns 200 OK with the updated banner data. + */ + router.post("/", async (req, res, next) => { + const { message, isActive } = req.body; + + // 1. Input Validation at the boundary (Criterio de Aceptación) + if (typeof message !== "string" || message.trim() === "") { + next(new BadRequestError("message must be a non-empty string")); + return; + } + + if (typeof isActive !== "boolean") { + next(new BadRequestError("isActive must be a boolean")); + return; + } + + try { + const correlationId = req.headers["x-request-id"] ?? req.headers["x-correlation-id"]; + + + const bannerData = { + message: message.trim(), + isActive, + updatedAt: new Date().toISOString() + }; + + // 2. Structured logging with correlation IDs (Guideline requerida) + logger.audit("SET_MAINTENANCE_BANNER", res.locals.adminActor, { + clientIp: getClientIp(req, TRUST_PROXY), + userAgent: req.get("User-Agent"), + correlationId, + diff: bannerData, + }); + + // 3. Standardized error envelope/response + res.status(200).json({ data: bannerData }); + } catch (error) { + if (error instanceof AppError) { + next(error); + return; + } + logger.error("Failed to set maintenance banner", { error }); + next(new InternalServerError()); + } + }); + + return router; +} + +export default createMaintenanceBannerRouter; \ No newline at end of file