refactoring to a server class

This commit is contained in:
StarAppeal
2025-09-19 23:43:45 +02:00
parent 65d87b77c6
commit 4bf62bbc83
5 changed files with 338 additions and 279 deletions
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
}
}
-144
View File
@@ -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();
});
});
+151
View File
@@ -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();
});
});
});