refactoring to a server class
This commit is contained in:
+10
-1
@@ -1,5 +1,14 @@
|
||||
type NodeEnv = "development" | "test" | "production";
|
||||
|
||||
interface BaseConfig {
|
||||
env: NodeEnv;
|
||||
port: number;
|
||||
cors: {
|
||||
origin: string;
|
||||
credentials: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function required(name: string, value: string | undefined): string {
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error(`Missing required env var: ${name}`);
|
||||
@@ -37,7 +46,7 @@ if (!isValidUrl(FRONTEND_URL)) {
|
||||
throw new Error("FRONTEND_URL must be a valid URL");
|
||||
}
|
||||
|
||||
export const config = {
|
||||
export const config :BaseConfig = {
|
||||
env: NODE_ENV,
|
||||
port: PORT,
|
||||
cors: {
|
||||
|
||||
+23
-134
@@ -1,146 +1,35 @@
|
||||
import express from "express";
|
||||
import {ExtendedWebSocketServer} from "./websocket";
|
||||
import {RestWebSocket} from "./rest/restWebSocket";
|
||||
import {RestUser} from "./rest/restUser";
|
||||
import {JwtTokenPropertiesExtractor} from "./rest/jwtTokenPropertiesExtractor";
|
||||
import cors from "cors";
|
||||
import {SpotifyTokenGenerator} from "./rest/spotifyTokenGenerator";
|
||||
import {RestAuth} from "./rest/auth";
|
||||
import {config} from "./config";
|
||||
import cookieParser from 'cookie-parser';
|
||||
import {authLimiter, spotifyLimiter} from "./rest/middleware/rateLimit";
|
||||
import {extractTokenFromCookie} from "./rest/middleware/extractTokenFromCookie";
|
||||
import {UserService} from "./db/services/db/UserService";
|
||||
import {randomUUID} from "crypto";
|
||||
import {JwtAuthenticator} from "./utils/jwtAuthenticator";
|
||||
import {authenticateJwt} from "./rest/middleware/authenticateJwt";
|
||||
import {disconnectFromDatabase} from "./db/services/db/database.service";
|
||||
import {SpotifyTokenService} from "./db/services/spotifyTokenService";
|
||||
|
||||
export async function startServer(jwtSecret: string, spotifyClientId: string, spotifyClientSecret: string) {
|
||||
const app = express();
|
||||
const port = config.port;
|
||||
import { Server } from "./server";
|
||||
import { config as baseConfig} from "./config";
|
||||
|
||||
app.set("trust proxy", 1);
|
||||
app.use(cookieParser());
|
||||
async function bootstrap() {
|
||||
const { SECRET_KEY, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET } = process.env;
|
||||
|
||||
app.use(cors({
|
||||
origin: config.cors.origin,
|
||||
credentials: config.cors.credentials,
|
||||
}));
|
||||
if (!SECRET_KEY || SECRET_KEY.length < 32) {
|
||||
throw new Error("CRITICAL ERROR: SECRET_KEY environment variable is not set or too short.");
|
||||
}
|
||||
if (!SPOTIFY_CLIENT_ID) {
|
||||
throw new Error("CRITICAL ERROR: SPOTIFY_CLIENT_ID environment variable is not set.");
|
||||
}
|
||||
if (!SPOTIFY_CLIENT_SECRET) {
|
||||
throw new Error("CRITICAL ERROR: SPOTIFY_CLIENT_SECRET environment variable is not set.");
|
||||
}
|
||||
|
||||
app.use((_req, res, next) => {
|
||||
res.set({
|
||||
"X-DNS-Prefetch-Control": "off",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Permissions-Policy": "geolocation=()",
|
||||
});
|
||||
next();
|
||||
// Server-Instanz mit Konfiguration erstellen
|
||||
const server = new Server({
|
||||
port: baseConfig.port,
|
||||
jwtSecret: SECRET_KEY,
|
||||
spotifyClientId: SPOTIFY_CLIENT_ID,
|
||||
spotifyClientSecret: SPOTIFY_CLIENT_SECRET,
|
||||
cors: baseConfig.cors,
|
||||
});
|
||||
|
||||
app.use(express.json({limit: "2mb"}));
|
||||
app.get("/api/healthz", (_req, res) => res.status(200).send({status: "ok"}));
|
||||
|
||||
console.log("Connecting to database and creating UserService...");
|
||||
const userService = await UserService.create();
|
||||
console.log("UserService created successfully.");
|
||||
|
||||
const spotifyTokenService = new SpotifyTokenService(spotifyClientId, spotifyClientSecret);
|
||||
|
||||
const _authenticateJwt = authenticateJwt(new JwtAuthenticator(jwtSecret));
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
});
|
||||
|
||||
const webSocketServer = new ExtendedWebSocketServer(server, userService, spotifyTokenService);
|
||||
const restWebSocket = new RestWebSocket(webSocketServer);
|
||||
const restUser = new RestUser(userService);
|
||||
const auth = new RestAuth(userService);
|
||||
const jwtTokenPropertiesExtractor = new JwtTokenPropertiesExtractor();
|
||||
const spotify = new SpotifyTokenGenerator(spotifyTokenService);
|
||||
|
||||
app.use("/api/auth", authLimiter, auth.createRouter());
|
||||
|
||||
app.use(extractTokenFromCookie);
|
||||
app.use("/api/spotify", _authenticateJwt, spotifyLimiter, spotify.createRouter());
|
||||
app.use("/api/websocket", _authenticateJwt, restWebSocket.createRouter());
|
||||
app.use("/api/user", _authenticateJwt, restUser.createRouter());
|
||||
app.use(
|
||||
"/api/jwt",
|
||||
_authenticateJwt,
|
||||
jwtTokenPropertiesExtractor.createRouter(),
|
||||
);
|
||||
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const errorId = randomUUID();
|
||||
|
||||
console.error(`Error ID: ${errorId} | Status: ${err?.status || 500} | Message: ${err?.message}`);
|
||||
console.error(`Stack Trace [${errorId}]:`, err.stack);
|
||||
|
||||
const statusCode = err?.status || 500;
|
||||
let errorMessage = err?.message || "Internal Server Error";
|
||||
let errorResponse: { ok: boolean; data: { error: string; errorId?: string } } = {
|
||||
ok: false,
|
||||
data: {
|
||||
error: errorMessage,
|
||||
}
|
||||
};
|
||||
|
||||
if (statusCode >= 500) {
|
||||
errorMessage = "An unexpected error occurred.";
|
||||
|
||||
errorResponse = {
|
||||
ok: false,
|
||||
data: {
|
||||
error: errorMessage,
|
||||
errorId: errorId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
res.status(statusCode).send(errorResponse);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("SIGTERM signal received: closing HTTP server");
|
||||
await disconnectFromDatabase();
|
||||
server.close(() => {
|
||||
console.log("HTTP server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
return {app, server};
|
||||
await server.start();
|
||||
}
|
||||
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const JWT_SECRET = process.env.SECRET_KEY;
|
||||
const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID;
|
||||
const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET;
|
||||
|
||||
if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
||||
console.error("CRITICAL ERROR: SECRET_KEY environment variable is not set or too short. Aborting.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!CLIENT_ID) {
|
||||
console.error("CRITICAL ERROR: CLIENT_ID environment variable is not set. Aborting.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!CLIENT_SECRET) {
|
||||
console.error("CRITICAL ERROR: CLIENT_SECRET environment variable is not set. Aborting.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
startServer(JWT_SECRET, CLIENT_ID, CLIENT_SECRET).catch(error => {
|
||||
console.error("Fatal error during server startup:", error);
|
||||
bootstrap().catch(error => {
|
||||
console.error("Fatal error during server startup:", error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import express, { Express, Request, Response, NextFunction } from "express";
|
||||
import { Server as HttpServer } from "http";
|
||||
import cors from "cors";
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { ExtendedWebSocketServer } from "./websocket";
|
||||
import { RestWebSocket } from "./rest/restWebSocket";
|
||||
import { RestUser } from "./rest/restUser";
|
||||
import { JwtTokenPropertiesExtractor } from "./rest/jwtTokenPropertiesExtractor";
|
||||
import { SpotifyTokenGenerator } from "./rest/spotifyTokenGenerator";
|
||||
import { RestAuth } from "./rest/auth";
|
||||
import { authLimiter, spotifyLimiter } from "./rest/middleware/rateLimit";
|
||||
import { extractTokenFromCookie } from "./rest/middleware/extractTokenFromCookie";
|
||||
import { UserService } from "./db/services/db/UserService";
|
||||
import { JwtAuthenticator } from "./utils/jwtAuthenticator";
|
||||
import { authenticateJwt } from "./rest/middleware/authenticateJwt";
|
||||
import { connectToDatabase, disconnectFromDatabase } from "./db/services/db/database.service";
|
||||
import { SpotifyTokenService } from "./db/services/spotifyTokenService";
|
||||
|
||||
interface ServerConfig {
|
||||
port: number;
|
||||
jwtSecret: string;
|
||||
spotifyClientId: string;
|
||||
spotifyClientSecret: string;
|
||||
cors: {
|
||||
origin: string | string[];
|
||||
credentials: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class Server {
|
||||
public readonly app: Express;
|
||||
private httpServer: HttpServer | null = null;
|
||||
private userService: UserService | null = null;
|
||||
private webSocketServer: ExtendedWebSocketServer | null = null;
|
||||
|
||||
constructor(private readonly config: ServerConfig) {
|
||||
this.app = express();
|
||||
}
|
||||
|
||||
public async start(): Promise<HttpServer> {
|
||||
await connectToDatabase();
|
||||
|
||||
this.userService = await UserService.create();
|
||||
const spotifyTokenService = new SpotifyTokenService(this.config.spotifyClientId, this.config.spotifyClientSecret);
|
||||
|
||||
this._setupMiddleware();
|
||||
this._setupRoutes(this.userService, spotifyTokenService);
|
||||
this._setupErrorHandling();
|
||||
|
||||
this.httpServer = this.app.listen(this.config.port, () => {
|
||||
console.log(`Server is running on port ${this.config.port}`);
|
||||
});
|
||||
|
||||
this.webSocketServer = new ExtendedWebSocketServer(this.httpServer, this.userService, spotifyTokenService);
|
||||
|
||||
this._setupGracefulShutdown();
|
||||
|
||||
return this.httpServer;
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
console.log("Stopping server gracefully...");
|
||||
await disconnectFromDatabase();
|
||||
if (this.httpServer) {
|
||||
this.httpServer.close(() => {
|
||||
console.log("HTTP server closed.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _setupMiddleware(): void {
|
||||
this.app.set("trust proxy", 1);
|
||||
this.app.use(cookieParser());
|
||||
this.app.use(cors({
|
||||
origin: this.config.cors.origin,
|
||||
credentials: this.config.cors.credentials,
|
||||
}));
|
||||
this.app.use(this._securityHeaders);
|
||||
this.app.use(express.json({ limit: "2mb" }));
|
||||
}
|
||||
|
||||
private _setupRoutes(userService: UserService, spotifyTokenService: SpotifyTokenService): void {
|
||||
const _authenticateJwt = authenticateJwt(new JwtAuthenticator(this.config.jwtSecret));
|
||||
|
||||
const restAuth = new RestAuth(userService);
|
||||
const restUser = new RestUser(userService);
|
||||
const spotifyTokenGenerator = new SpotifyTokenGenerator(spotifyTokenService);
|
||||
const jwtTokenExtractor = new JwtTokenPropertiesExtractor();
|
||||
|
||||
this.app.get("/api/healthz", (_req, res) => res.status(200).send({ status: "ok" }));
|
||||
|
||||
this.app.use("/api/auth", authLimiter, restAuth.createRouter());
|
||||
|
||||
this.app.use(extractTokenFromCookie);
|
||||
this.app.use("/api/spotify", _authenticateJwt, spotifyLimiter, spotifyTokenGenerator.createRouter());
|
||||
this.app.use("/api/user", _authenticateJwt, restUser.createRouter());
|
||||
this.app.use("/api/jwt", _authenticateJwt, jwtTokenExtractor.createRouter());
|
||||
|
||||
this.app.use("/api/websocket", _authenticateJwt, (req, res, next) => {
|
||||
if (this.webSocketServer) {
|
||||
const restWebSocket = new RestWebSocket(this.webSocketServer);
|
||||
restWebSocket.createRouter()(req, res, next);
|
||||
} else {
|
||||
next(new Error("WebSocket server not initialized."));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _securityHeaders(_req: Request, res: Response, next: NextFunction): void {
|
||||
res.set({
|
||||
"X-DNS-Prefetch-Control": "off",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Permissions-Policy": "geolocation=()",
|
||||
});
|
||||
next();
|
||||
}
|
||||
|
||||
private _setupErrorHandling(): void {
|
||||
this.app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||
const errorId = randomUUID();
|
||||
const statusCode = err?.status || 500;
|
||||
|
||||
console.error(`Error ID: ${errorId} | Status: ${statusCode} | Message: ${err?.message}`);
|
||||
if (err.stack) {
|
||||
console.error(`Stack Trace [${errorId}]:`, err.stack);
|
||||
}
|
||||
|
||||
let errorMessage = err?.message || "Internal Server Error";
|
||||
if (statusCode >= 500) {
|
||||
errorMessage = "An unexpected error occurred on the server.";
|
||||
}
|
||||
|
||||
res.status(statusCode).send({
|
||||
ok: false,
|
||||
data: {
|
||||
error: errorMessage,
|
||||
...(statusCode >= 500 && { errorId: errorId }),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _setupGracefulShutdown(): void {
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("SIGTERM signal received. Closing server gracefully.");
|
||||
await this.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import request from "supertest";
|
||||
import express, {Router} from "express";
|
||||
import http from "http";
|
||||
import { authLimiter } from "../src/rest/middleware/rateLimit";
|
||||
|
||||
vi.mock("../src/db/services/db/database.service", () => ({
|
||||
connectToDatabase: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../src/websocket", () => ({
|
||||
ExtendedWebSocketServer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../src/config", () => ({
|
||||
config: {
|
||||
port: 3001,
|
||||
cors: { origin: "http://test-origin.com", credentials: true },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../src/rest/middleware/rateLimit", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("../src/rest/middleware/rateLimit")>();
|
||||
return {
|
||||
...original,
|
||||
authLimiter: vi.fn((req, res, next) => next()),
|
||||
spotifyLimiter: vi.fn((req, res, next) => next()),
|
||||
};
|
||||
});
|
||||
|
||||
// feels kinda hacky tbh
|
||||
vi.mock("../src/rest/auth", () => {
|
||||
const RestAuth = vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
createRouter: () => {
|
||||
const router = Router();
|
||||
|
||||
router.get("/test-500-error", (_req, _res, _next) => {
|
||||
throw new Error("Simulated internal server error!");
|
||||
});
|
||||
|
||||
router.get("/test-400-error", (_req, _res, next) => {
|
||||
const clientError = new Error("Simulated client error.");
|
||||
(clientError as any).status = 400;
|
||||
next(clientError);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
};
|
||||
});
|
||||
return { RestAuth };
|
||||
});
|
||||
|
||||
let app: express.Application;
|
||||
let server: http.Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { startServer } = await import("../src/index");
|
||||
|
||||
const instances = await startServer("not-used", "not-used", "not-used");
|
||||
app = instances.app;
|
||||
server = instances.server;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (server) {
|
||||
server.close(() => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Express App Integration Test", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should start and respond to the healthz endpoint", async () => {
|
||||
const response = await request(app).get("/api/healthz").expect(200);
|
||||
expect(response.body).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
it("should apply CORS headers based on the configuration", async () => {
|
||||
const response = await request(app)
|
||||
.options("/api/healthz")
|
||||
.set("Origin", "http://test-origin.com")
|
||||
.expect(204);
|
||||
|
||||
expect(response.headers['access-control-allow-origin']).toBe("http://test-origin.com");
|
||||
expect(response.headers['access-control-allow-credentials']).toBe("true");
|
||||
});
|
||||
|
||||
it("should apply security headers to responses", async () => {
|
||||
const response = await request(app).get("/api/healthz").expect(200);
|
||||
expect(response.headers['x-frame-options']).toBe('DENY');
|
||||
expect(response.headers['referrer-policy']).toBe('no-referrer');
|
||||
});
|
||||
|
||||
it("should protect a route with authentication middleware", async () => {
|
||||
await request(app).get("/api/user/me").expect(401);
|
||||
});
|
||||
|
||||
it("should apply the auth rate limiter to an auth route", async () => {
|
||||
await request(app).post("/api/auth/login").send({});
|
||||
expect(authLimiter).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should NOT apply the auth rate limiter to a non-auth route", async () => {
|
||||
await request(app).get("/api/healthz").expect(200);
|
||||
expect(authLimiter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return a 404 for an unknown route", async () => {
|
||||
await request(app).get("/api/this-route-does-not-exist").expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling Middleware", () => {
|
||||
|
||||
it("should handle a 500 internal server error and return a generic message with an errorId", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/auth/test-500-error")
|
||||
.expect(500);
|
||||
|
||||
expect(response.body.ok).toBe(false);
|
||||
expect(response.body.data.error).toBe("An unexpected error occurred.");
|
||||
expect(response.body.data.errorId).toBeDefined();
|
||||
expect(typeof response.body.data.errorId).toBe("string");
|
||||
});
|
||||
|
||||
it("should handle a 400 client error and return the specific message without an errorId", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/auth/test-400-error")
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.ok).toBe(false);
|
||||
expect(response.body.data.error).toBe("Simulated client error.");
|
||||
expect(response.body.data.errorId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import request from "supertest";
|
||||
import { Server } from "../src/server";
|
||||
import { Router, type Request, type Response, type NextFunction } from "express"; // Import Express types
|
||||
import type { Express } from "express";
|
||||
import { authLimiter } from "../src/rest/middleware/rateLimit";
|
||||
|
||||
vi.mock("../src/db/services/db/database.service", () => ({
|
||||
connectToDatabase: vi.fn().mockResolvedValue(undefined),
|
||||
disconnectFromDatabase: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("../src/db/services/db/UserService", () => ({
|
||||
UserService: { create: vi.fn().mockResolvedValue({}) },
|
||||
}));
|
||||
vi.mock("../src/db/services/spotifyTokenService", () => ({ SpotifyTokenService: vi.fn() }));
|
||||
|
||||
vi.mock("../src/websocket", () => ({
|
||||
ExtendedWebSocketServer: vi.fn().mockImplementation(() => {
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../src/rest/middleware/rateLimit", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("../src/rest/middleware/rateLimit")>();
|
||||
return {
|
||||
...original,
|
||||
authLimiter: vi.fn((req: Request, res: Response, next: NextFunction) => next()),
|
||||
spotifyLimiter: vi.fn((req: Request, res: Response, next: NextFunction) => next()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../src/rest/middleware/authenticateJwt", () => ({
|
||||
authenticateJwt: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../src/rest/auth", () => {
|
||||
const MockRestAuth = vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
createRouter: () => {
|
||||
const router = Router();
|
||||
router.get("/test-500-error", (_req, _res, _next) => {
|
||||
throw new Error("Simulated internal server error!");
|
||||
});
|
||||
router.get("/test-400-error", (_req, _res, next) => {
|
||||
const clientError: Error & { status?: number } = new Error("Simulated client error.");
|
||||
clientError.status = 400;
|
||||
next(clientError);
|
||||
});
|
||||
router.post("/login", (req, res) => res.status(200).send("ok"));
|
||||
return router;
|
||||
}
|
||||
};
|
||||
});
|
||||
return { RestAuth: MockRestAuth };
|
||||
});
|
||||
|
||||
const mockServerConfig = {
|
||||
port: 8888,
|
||||
jwtSecret: "a-very-secure-test-secret-that-is-at-least-32-chars-long",
|
||||
spotifyClientId: "test-id",
|
||||
spotifyClientSecret: "test-secret",
|
||||
cors: {
|
||||
origin: "http://test-origin.com",
|
||||
credentials: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe("Server Class Integration Tests", () => {
|
||||
let server: Server;
|
||||
let app: Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
server = new Server(mockServerConfig);
|
||||
await server.start();
|
||||
app = server.app;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
describe("Server Setup and Middleware", () => {
|
||||
it("should start and respond to the healthz endpoint", async () => {
|
||||
const response = await request(app).get("/api/healthz").expect(200);
|
||||
expect(response.body).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
it("should apply CORS headers based on the configuration", async () => {
|
||||
const response = await request(app)
|
||||
.options("/api/healthz")
|
||||
.set("Origin", "http://test-origin.com")
|
||||
.expect(204);
|
||||
|
||||
expect(response.headers['access-control-allow-origin']).toBe("http://test-origin.com");
|
||||
expect(response.headers['access-control-allow-credentials']).toBe("true");
|
||||
});
|
||||
|
||||
it("should apply security headers to responses", async () => {
|
||||
const response = await request(app).get("/api/healthz").expect(200);
|
||||
expect(response.headers['x-frame-options']).toBe('DENY');
|
||||
expect(response.headers['referrer-policy']).toBe('no-referrer');
|
||||
});
|
||||
|
||||
it("should apply the auth rate limiter to an auth route", async () => {
|
||||
await request(app).post("/api/auth/login").send({});
|
||||
expect(authLimiter).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should NOT apply the auth rate limiter to a non-auth route", async () => {
|
||||
await request(app).get("/api/healthz").expect(200);
|
||||
expect(authLimiter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Routing and Authentication", () => {
|
||||
it("should protect a route with authentication middleware", async () => {
|
||||
// The default mock for authenticateJwt returns 401, so this should fail as expected
|
||||
await request(app).get("/api/user").expect(401);
|
||||
});
|
||||
|
||||
it("should return a 404 for an unknown route", async () => {
|
||||
await request(app).get("/api/this-route-does-not-exist").expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling Middleware", () => {
|
||||
it("should handle a 500 internal server error and return a generic message with an errorId", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/auth/test-500-error")
|
||||
.expect(500);
|
||||
|
||||
expect(response.body.ok).toBe(false);
|
||||
expect(response.body.data.error).toBe("An unexpected error occurred on the server.");
|
||||
expect(response.body.data.errorId).toBeDefined();
|
||||
expect(typeof response.body.data.errorId).toBe("string");
|
||||
});
|
||||
|
||||
it("should handle a 400 client error and return the specific message without an errorId", async () => {
|
||||
const response = await request(app)
|
||||
.get("/api/auth/test-400-error")
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.ok).toBe(false);
|
||||
expect(response.body.data.error).toBe("Simulated client error.");
|
||||
expect(response.body.data.errorId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user