add validation middleware and improve REST API structure

This commit is contained in:
StarAppeal
2025-09-06 03:47:44 +02:00
parent b3381e04e3
commit 3a939c2b36
24 changed files with 4044 additions and 264 deletions
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import request from "supertest";
import express from "express";
import { authLimiter, spotifyLimiter } from "../../../src/rest/middleware/rateLimit";
function createTestApp() {
const app = express();
app.set("trust proxy", 1);
app.get("/auth-test", authLimiter, (_req, res) => res.status(200).send({ ok: true }));
app.get("/spotify-test", spotifyLimiter, (_req, res) => res.status(200).send({ ok: true }));
return app;
}
async function hit(app: express.Express, path: string, times: number) {
for (let i = 0; i < times; i++) {
await request(app).get(path);
}
}
describe("RateLimit", () => {
it("limits /auth-test after 30 Requests, returns http 429", async () => {
const app = createTestApp();
// 30 are allowed
await hit(app, "/auth-test", 30);
// afterwards, any request returns 429
const res = await request(app).get("/auth-test");
expect(res.status).toBe(429);
expect(res.headers["ratelimit-policy"]).toBeTruthy();
});
it("limits /spotify-test after 60 requests, returns http 429", async () => {
const app = createTestApp();
await hit(app, "/spotify-test", 60);
const res = await request(app).get("/spotify-test");
expect(res.status).toBe(429);
});
});
+206
View File
@@ -0,0 +1,206 @@
import { describe, it, expect, vi } from "vitest";
import { v, validateBody, validateParams, validateQuery } from "../../../src/rest/middleware/validate";
describe("v.isString", () => {
it("accepts a simple string", () => {
const res = v.isString()("abc");
expect(res).toBe(true);
});
it("rejects non strings", () => {
const res = v.isString()(123 as any);
expect(res).toBe("must be a string");
});
it("forces nonEmpty", () => {
const res = v.isString({ nonEmpty: true })(" ");
expect(res).toBe("must be a non-empty string");
});
it("checks min/max length", () => {
expect(v.isString({ min: 3 })("ab")).toBe("must be at least 3 chars");
expect(v.isString({ max: 3 })("abcd")).toBe("must be at most 3 chars");
expect(v.isString({ min: 2, max: 4 })("abc")).toBe(true);
});
});
describe("v.isNumber", () => {
it("accepts numbers, rejects NaN", () => {
expect(v.isNumber()(10)).toBe(true);
expect(v.isNumber()(Number.NaN)).toBe("must be a number");
expect(v.isNumber()("10")).toBe("must be a number");
});
it("checks integer min/max", () => {
expect(v.isNumber({ integer: true })(1.2)).toBe("must be an integer");
expect(v.isNumber({ min: 5 })(4)).toBe("must be >= 5");
expect(v.isNumber({ max: 5 })(6)).toBe("must be <= 5");
expect(v.isNumber({ integer: true, min: 0, max: 10 })(10)).toBe(true);
});
});
describe("v.isBoolean", () => {
it("accepts true/false", () => {
expect(v.isBoolean()(true)).toBe(true);
expect(v.isBoolean()(false)).toBe(true);
});
it("rejects other types", () => {
expect(v.isBoolean()("true" )).toBe("must be a boolean");
expect(v.isBoolean()(0)).toBe("must be a boolean");
});
});
describe("v.isEnum", () => {
it("accepts only correct values", () => {
const validator = v.isEnum(["A", "B", "C"] as const);
expect(validator("A")).toBe(true);
expect(validator("D")).toBe("must be one of: A, B, C");
});
});
describe("v.isArrayLength", () => {
it("checks the exact length", () => {
expect(v.isArrayLength(2)([1, 2])).toBe(true);
expect(v.isArrayLength(2)([1])).toBe("must be an array of length 2");
expect(v.isArrayLength(2)("not array")).toBe("must be an array of length 2");
});
});
describe("v.isUrl", () => {
it("accepts valid urls", () => {
expect(v.isUrl()("https://example.com")).toBe(true);
});
it("rejects invalid URLs and nom-strings", () => {
expect(v.isUrl()("notaurl")).toBe("must be a valid URL");
expect(v.isUrl()(123 as any)).toBe("must be a string URL");
});
});
describe("validateBody Middleware", () => {
const schema = {
name: { required: true, validator: v.isString({ nonEmpty: true }) },
age: { required: false, validator: v.isNumber({ min: 0, integer: true }) },
};
function makeRes() {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
return res;
}
it("calls next(), when valid", async () => {
const req: any = { body: { name: "Alice", age: 30 } };
const res = makeRes();
const next = vi.fn();
const mw = validateBody(schema);
mw(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
expect(res.send).not.toHaveBeenCalled();
});
it("sends 400 with missing/incorrect fields", async () => {
const req: any = { body: { name: " ", age: -1 } };
const res = makeRes();
const next = vi.fn();
const mw = validateBody(schema);
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({
error: "Validation failed",
details: expect.arrayContaining([
"name must be a non-empty string",
"age must be >= 0",
]),
})
);
});
it("correctly checks missing fields", async () => {
const req: any = { body: { /* no name */ } };
const res = makeRes();
const next = vi.fn();
const mw = validateBody(schema);
mw(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({
error: "Validation failed",
details: expect.arrayContaining(["name is required"]),
})
);
});
});
describe("validateParams and validateQuery middleware", () => {
const paramSchema = {
id: { required: true, validator: v.isString({ nonEmpty: true }) },
};
const querySchema = {
limit: { required: false, validator: v.isNumber({ integer: true, min: 1, max: 100 }) },
};
function makeRes() {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
return res;
}
it("validateParams: ok -> next()", () => {
const req: any = { params: { id: "abc" } };
const res = makeRes();
const next = vi.fn();
validateParams(paramSchema)(req, res, next);
expect(next).toHaveBeenCalledOnce();
});
it("validateParams: error -> 400", () => {
const req: any = { params: { id: " " } };
const res = makeRes();
const next = vi.fn();
validateParams(paramSchema)(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalled();
});
it("validateQuery: ok without limit", () => {
const req: any = { query: { } };
const res = makeRes();
const next = vi.fn();
validateQuery(querySchema)(req, res, next);
expect(next).toHaveBeenCalledOnce();
});
it("validateQuery: error on limit outside range", () => {
const req: any = { query: { limit: 101 } };
const res = makeRes();
const next = vi.fn();
validateQuery(querySchema)(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({
error: "Validation failed",
details: expect.arrayContaining(["limit must be <= 100"]),
})
);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("jsonwebtoken", () => {
return {
default: {
verify: vi.fn(),
sign: vi.fn(),
},
};
});
import jwt from "jsonwebtoken";
import { JwtAuthenticator } from "../../src/utils/jwtAuthenticator";
describe("JwtAuthenticator", () => {
const secret = "test-secret";
let auth: JwtAuthenticator;
beforeEach(() => {
auth = new JwtAuthenticator(secret);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("verifyToken returns null when no token is passed", () => {
expect(auth.verifyToken(undefined)).toBeNull();
expect(jwt.verify).not.toHaveBeenCalled();
});
it("verifyToken returns DecodedToken when verify was successful ", () => {
const payload = { username: "alice", id: "1", uuid: "u-1" };
(jwt.verify as any).mockReturnValue(payload);
const res = auth.verifyToken("valid.jwt.token");
expect(jwt.verify).toHaveBeenCalledWith("valid.jwt.token", secret);
expect(res).toEqual(payload);
});
it("verifyToken returns null when verify throws error", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
(jwt.verify as any).mockImplementation(() => {
throw new Error("invalid");
});
const res = auth.verifyToken("broken.token");
expect(res).toBeNull();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
it("generateToken signs payload with secret", () => {
(jwt.sign as any).mockReturnValue("signed.jwt");
const payload = { username: "bob" } as any;
const token = auth.generateToken(payload);
expect(jwt.sign).toHaveBeenCalledWith(payload, secret);
expect(token).toBe("signed.jwt");
});
});
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { hashMock, compareMock } = vi.hoisted(() => ({
hashMock: vi.fn(),
compareMock: vi.fn(),
}));
vi.mock("bcrypt", () => {
return {
hash: hashMock,
compare: compareMock,
default: {
hash: hashMock,
compare: compareMock,
},
};
});
import { PasswordUtils } from "../../src/utils/passwordUtils";
describe("PasswordUtils", () => {
beforeEach(() => {
hashMock.mockReset();
compareMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("hashPassword uses bcrypt.hash with 10 saltrounds", async () => {
hashMock.mockResolvedValue("hashed");
const res = await PasswordUtils.hashPassword("secret");
expect(hashMock).toHaveBeenCalledWith("secret", 10);
expect(res).toBe("hashed");
});
it("comparePassword uses bcrypt.compare", async () => {
compareMock.mockResolvedValue(true);
const ok = await PasswordUtils.comparePassword("secret", "hashed");
expect(compareMock).toHaveBeenCalledWith("secret", "hashed");
expect(ok).toBe(true);
});
describe("validatePassword", () => {
it("fails when password too short", () => {
const res = PasswordUtils.validatePassword("A1!");
expect(res.valid).toBe(false);
expect(res.message).toMatch(/mindestens 8 Zeichen/);
});
it("fails without capital letter", () => {
const res = PasswordUtils.validatePassword("password1!");
expect(res.valid).toBe(false);
expect(res.message).toMatch(/Großbuchstaben/);
});
it("fails without uncapitalized letter", () => {
const res = PasswordUtils.validatePassword("PASSWORD1!");
expect(res.valid).toBe(false);
expect(res.message).toMatch(/Kleinbuchstaben/);
});
it("fails without number", () => {
const res = PasswordUtils.validatePassword("Password!");
expect(res.valid).toBe(false);
expect(res.message).toMatch(/Zahl/);
});
it("fails without special characters", () => {
const res = PasswordUtils.validatePassword("Password1");
expect(res.valid).toBe(false);
expect(res.message).toMatch(/Sonderzeichen/);
});
it("accepts valid password", () => {
const res = PasswordUtils.validatePassword("ValidPassword1!");
expect(res.valid).toBe(true);
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("../../src/utils/jwtAuthenticator", () => {
return {
JwtAuthenticator: vi.fn().mockImplementation(() => ({
verifyToken: mockVerifyToken,
})),
};
});
const mockVerifyToken = vi.fn();
import type { IncomingMessage } from "node:http";
import { verifyClient } from "../../src/utils/verifyClient";
describe("verifyClient", () => {
const cb = vi.fn();
let consoleSpy: ReturnType<typeof vi.spyOn>;
function makeReq(authHeader?: string) {
const headers: Record<string, string> = {};
if (authHeader) headers["authorization"] = authHeader;
// socket infos just for log
const socket: any = { remoteAddress: "127.0.0.1", remotePort: 12345 };
return { headers, socket } as unknown as IncomingMessage & { [k: string]: any };
}
beforeEach(() => {
cb.mockReset();
mockVerifyToken.mockReset();
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
it("accepts connections with valid token and sets payload", () => {
const req = makeReq("Bearer valid.jwt");
mockVerifyToken.mockReturnValue({ sub: "user-1" });
verifyClient(req, cb);
expect(mockVerifyToken).toHaveBeenCalledWith("valid.jwt");
expect(cb).toHaveBeenCalledWith(true);
expect((req as any).payload).toEqual({ sub: "user-1" });
});
it("Rejects connection if no Authorization header is set", () => {
const req = makeReq(undefined);
mockVerifyToken.mockReturnValue(null);
verifyClient(req, cb);
expect(cb).toHaveBeenCalledWith(false, 401, "Unauthorized");
expect(consoleSpy).toHaveBeenCalled();
});
it("rejects connection, if token is invalid", () => {
const req = makeReq("Bearer bad.jwt");
mockVerifyToken.mockReturnValue(null);
verifyClient(req, cb);
expect(mockVerifyToken).toHaveBeenCalledWith("bad.jwt");
expect(cb).toHaveBeenCalledWith(false, 401, "Unauthorized");
});
it("extracts token correctly after 'Bearer ' prefix", () => {
const expectedToken = " fancy.token.with.spaces ";
const req = makeReq(`Bearer ${expectedToken}`);
mockVerifyToken.mockReturnValue({ ok: true });
verifyClient(req, cb);
expect(mockVerifyToken).toHaveBeenCalledWith(expectedToken);
expect(cb).toHaveBeenCalledWith(true);
});
});
@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
import { getEventListeners } from "../../../../src/utils/websocket/websocketCustomEvents/websocketEventUtils";
import { WebsocketEventType } from "../../../../src/utils/websocket/websocketCustomEvents/websocketEventType";
describe("websocketEventUtils.getEventListeners", () => {
function makeWs() {
return {
user: {
timezone: "Europe/Berlin",
lastState: { global: { mode: "idle", brightness: 42 } },
},
send: vi.fn(),
};
}
it("returns a list of event-handlers incl. GET_STATE/GET_SETTINGS", async () => {
const ws: any = makeWs();
const listeners = getEventListeners(ws);
expect(Array.isArray(listeners)).toBe(true);
expect(listeners.length).toBeGreaterThan(0);
const byType = Object.fromEntries(listeners.map(l => [l.event, l]));
expect(byType[WebsocketEventType.GET_STATE]).toBeTruthy();
byType[WebsocketEventType.GET_STATE].handler({});
expect(ws.send).toHaveBeenCalledWith(
JSON.stringify({ type: "STATE", payload: ws.user.lastState }),
{ binary: false },
);
ws.send.mockClear();
expect(byType[WebsocketEventType.GET_SETTINGS]).toBeTruthy();
byType[WebsocketEventType.GET_SETTINGS].handler({});
expect(ws.send).toHaveBeenCalledWith(
JSON.stringify({ type: "SETTINGS", payload: { timezone: ws.user.timezone } }),
{ binary: false },
);
});
});
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { heartbeatSpy, getUserByUUID } = vi.hoisted(() => ({
heartbeatSpy: vi.fn(),
getUserByUUID: vi.fn(),
}));
vi.mock("../../../src/utils/websocket/websocketServerHeartbeatInterval", () => {
return {
heartbeat: () => heartbeatSpy,
};
});
const userObj = {
name: "tester",
uuid: "uuid-1",
timezone: "Europe/Berlin",
location: "Berlin",
lastState: { global: { mode: "idle", brightness: 50 } },
};
vi.mock("../../../src/db/services/db/UserService", () => {
return {
UserService: {
create: vi.fn().mockResolvedValue({
getUserByUUID,
}),
},
};
});
class FakeWSS {
clients = new Set<any>();
handlers = new Map<string, Function>();
on(event: string, handler: Function) {
this.handlers.set(event, handler);
}
emit(event: string, ...args: any[]) {
const h = this.handlers.get(event);
if (h) h(...args);
}
}
import { WebsocketServerEventHandler } from "../../../src/utils/websocket/websocketServerEventHandler";
describe("WebsocketServerEventHandler", () => {
let wss: FakeWSS;
beforeEach(() => {
wss = new FakeWSS();
heartbeatSpy.mockReset();
getUserByUUID.mockReset();
getUserByUUID.mockResolvedValue(userObj);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("enableConnectionEvent sets user/payload/isAlive/asyncUpdates and calls callback", async () => {
const handler = new WebsocketServerEventHandler(wss as any);
const cb = vi.fn();
const done = new Promise<void>((resolve) => {
cb.mockImplementation(() => resolve());
});
handler.enableConnectionEvent(cb);
const req = { payload: { uuid: "uuid-1" } };
const ws: any = {};
wss.emit("connection", ws, req);
await done;
expect(getUserByUUID).toHaveBeenCalledWith("uuid-1");
expect(ws.user).toEqual(userObj);
expect(ws.payload).toEqual(req.payload);
expect(ws.isAlive).toBe(true);
expect(ws.asyncUpdates).toBeInstanceOf(Map);
expect(cb).toHaveBeenCalledWith(ws, req);
});
it("enableHeartbeat starts interval and calls heartbeat()", () => {
vi.useFakeTimers();
const handler = new WebsocketServerEventHandler(wss as any);
const id = handler.enableHeartbeat(1000);
expect(["number", "object"]).toContain(typeof id);
vi.advanceTimersByTime(3000);
expect(heartbeatSpy).toHaveBeenCalledTimes(3);
clearInterval(id);
vi.useRealTimers();
});
it("enableCloseEvent registers Listener and calls callback on close", () => {
const handler = new WebsocketServerEventHandler(wss as any);
const cb = vi.fn();
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
handler.enableCloseEvent(cb);
wss.emit("close");
expect(cb).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith("WebSocket server closed");
logSpy.mockRestore();
});
});
@@ -0,0 +1,46 @@
import {describe, it, expect, vi, beforeEach, afterEach} from "vitest";
import {heartbeat} from "../../../src/utils/websocket/websocketServerHeartbeatInterval";
describe("heartbeat(wss)", () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {
});
});
afterEach(() => {
consoleSpy.mockRestore();
});
function makeClient({
isAlive,
username,
}: {
isAlive: boolean;
username: string;
}) {
return {
isAlive,
payload: {username},
ping: vi.fn(),
terminate: vi.fn(),
} as any;
}
it("terminated dead clients and pings alive ones, sets isAlive to false", () => {
const alive = makeClient({isAlive: true, username: "alive-user"});
const dead = makeClient({isAlive: false, username: "dead-user"});
const wss = {
clients: new Set<any>([alive, dead]),
} as any;
const hb = heartbeat(wss);
hb();
expect(dead.terminate).toHaveBeenCalledTimes(1);
expect(alive.ping).toHaveBeenCalledTimes(1);
expect(alive.isAlive).toBe(false);
});
});