Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/errors/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
6 changes: 6 additions & 0 deletions src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
60 changes: 60 additions & 0 deletions src/routes/admin/maintenance/banner.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
82 changes: 82 additions & 0 deletions src/routes/admin/maintenance/banner.ts
Original file line number Diff line number Diff line change
@@ -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;