mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-29 12:14:50 +02:00
34 added documentation and tests
34 corrected formatting
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/server/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
schema: "./src/lib/server/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: { url: process.env.DATABASE_URL },
|
||||
verbose: true,
|
||||
strict: true
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test('home page has expected h1', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
test("home page has expected h1", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("h1")).toBeVisible();
|
||||
});
|
||||
|
||||
+12
-12
@@ -1,13 +1,13 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import js from '@eslint/js';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
import prettier from "eslint-config-prettier";
|
||||
import js from "@eslint/js";
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import globals from "globals";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript-eslint";
|
||||
import svelteConfig from "./svelte.config.js";
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
|
||||
|
||||
export default ts.config(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
@@ -20,14 +20,14 @@ export default ts.config(
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
},
|
||||
rules: { 'no-undef': 'off' }
|
||||
rules: { "no-undef": "off" }
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
extraFileExtensions: [".svelte"],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
command: "npm run build && npm run preview",
|
||||
port: 4173
|
||||
},
|
||||
testDir: 'e2e'
|
||||
testDir: "e2e"
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
describe("sum test", () => {
|
||||
it("adds 1 + 2 to equal 3", () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { handle } from "./hooks.server.js";
|
||||
|
||||
// Mock the Date.now function for rate limiting tests
|
||||
const mockDateNow = vi.fn();
|
||||
vi.stubGlobal("Date", { ...Date, now: mockDateNow });
|
||||
|
||||
describe("hooks.server", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDateNow.mockReturnValue(1000000); // Fixed timestamp for consistent testing
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Reset any global state
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("rate limiting", () => {
|
||||
const mockResolve = vi.fn();
|
||||
const createEvent = (ip: string = "192.168.1.1", method: string = "GET") => ({
|
||||
url: new URL("http://localhost/api/test"),
|
||||
request: new Request("http://localhost/api/test", {
|
||||
method,
|
||||
headers: {
|
||||
"x-forwarded-for": ip
|
||||
}
|
||||
}),
|
||||
cookies: {} as any,
|
||||
fetch: {} as any,
|
||||
getClientAddress: () => ip,
|
||||
locals: {},
|
||||
params: {},
|
||||
route: { id: null },
|
||||
setHeaders: vi.fn(),
|
||||
isDataRequest: false,
|
||||
isSubRequest: false,
|
||||
platform: {} as any
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolve.mockResolvedValue(new Response("OK"));
|
||||
});
|
||||
|
||||
it("should allow requests within rate limit", async () => {
|
||||
const event = createEvent();
|
||||
|
||||
const response = await handle({ event, resolve: mockResolve });
|
||||
|
||||
expect(response.status).not.toBe(429);
|
||||
expect(mockResolve).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should block requests when rate limit exceeded", async () => {
|
||||
const event = createEvent();
|
||||
|
||||
// Make multiple requests to exceed rate limit
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await handle({ event, resolve: mockResolve });
|
||||
}
|
||||
|
||||
// This request should be rate limited
|
||||
const response = await handle({ event, resolve: mockResolve });
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(await response.text()).toBe("Too Many Requests");
|
||||
});
|
||||
|
||||
it("should reset rate limit after window expires", async () => {
|
||||
const event = createEvent();
|
||||
|
||||
// Exceed rate limit
|
||||
for (let i = 0; i < 11; i++) {
|
||||
await handle({ event, resolve: mockResolve });
|
||||
}
|
||||
|
||||
// Mock time passing (would need to mock Date.now in real implementation)
|
||||
// For now, we'll test that different IPs are treated separately
|
||||
const differentIPEvent = createEvent("192.168.1.2");
|
||||
const response = await handle({ event: differentIPEvent, resolve: mockResolve });
|
||||
|
||||
expect(response.status).not.toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CORS handling", () => {
|
||||
const mockResolve = vi.fn();
|
||||
|
||||
const createCORSEvent = (method: string = "GET", path: string = "/api/test") => ({
|
||||
url: new URL(`http://localhost${path}`),
|
||||
request: new Request(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-forwarded-for": "192.168.2.1" // Different IP for CORS tests
|
||||
}
|
||||
}),
|
||||
cookies: {} as any,
|
||||
fetch: {} as any,
|
||||
getClientAddress: () => "192.168.2.1",
|
||||
locals: {},
|
||||
params: {},
|
||||
route: { id: null },
|
||||
setHeaders: vi.fn(),
|
||||
isDataRequest: false,
|
||||
isSubRequest: false,
|
||||
platform: {} as any
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolve.mockResolvedValue(new Response("OK"));
|
||||
});
|
||||
|
||||
it("should handle OPTIONS preflight requests", async () => {
|
||||
const event = createCORSEvent("OPTIONS");
|
||||
|
||||
const response = await handle({ event, resolve: mockResolve });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET");
|
||||
expect(mockResolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should add CORS headers to API routes", async () => {
|
||||
const event = createCORSEvent();
|
||||
|
||||
const response = await handle({ event, resolve: mockResolve });
|
||||
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("security headers", () => {
|
||||
const mockResolve = vi.fn();
|
||||
|
||||
const createSecurityEvent = (protocol: string = "http", path: string = "/test") => ({
|
||||
url: new URL(`${protocol}://localhost${path}`),
|
||||
request: new Request(`${protocol}://localhost${path}`, {
|
||||
headers: {
|
||||
"x-forwarded-for": "192.168.3.1" // Different IP for security tests
|
||||
}
|
||||
}),
|
||||
cookies: {} as any,
|
||||
fetch: {} as any,
|
||||
getClientAddress: () => "192.168.3.1",
|
||||
locals: {},
|
||||
params: {},
|
||||
route: { id: null },
|
||||
setHeaders: vi.fn(),
|
||||
isDataRequest: false,
|
||||
isSubRequest: false,
|
||||
platform: {} as any
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockResolve.mockResolvedValue(new Response("OK"));
|
||||
});
|
||||
|
||||
it("should add security headers to all responses", async () => {
|
||||
const event = createSecurityEvent();
|
||||
|
||||
const response = await handle({ event, resolve: mockResolve });
|
||||
|
||||
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block");
|
||||
expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
|
||||
expect(response.headers.get("Content-Security-Policy")).toContain("default-src 'self'");
|
||||
});
|
||||
|
||||
it("should add HSTS header for HTTPS requests", async () => {
|
||||
const event = createSecurityEvent("https");
|
||||
|
||||
const response = await handle({ event, resolve: mockResolve });
|
||||
|
||||
expect(response.headers.get("Strict-Transport-Security")).toBe(
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not add HSTS header for HTTP requests", async () => {
|
||||
const event = createSecurityEvent("http");
|
||||
|
||||
const response = await handle({ event, resolve: mockResolve });
|
||||
|
||||
expect(response.headers.get("Strict-Transport-Security")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
+83
-44
@@ -1,45 +1,68 @@
|
||||
import { type Handle } from '@sveltejs/kit';
|
||||
import { type Handle } from "@sveltejs/kit";
|
||||
|
||||
/** In-memory store for rate limiting records per client IP */
|
||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
||||
|
||||
/** Rate limiting window duration in milliseconds */
|
||||
const RATE_LIMIT_WINDOW = 1000; // ms
|
||||
/** Maximum requests allowed per rate limiting window */
|
||||
const RATE_LIMIT_MAX_REQUESTS = 10;
|
||||
|
||||
/**
|
||||
* Extracts the client IP address from request headers
|
||||
*
|
||||
* Checks headers in order of preference:
|
||||
* 1. x-forwarded-for (takes first IP from comma-separated list)
|
||||
* 2. x-real-ip
|
||||
* 3. Returns 'unknown' if no IP found
|
||||
*
|
||||
* @param {Request} request - The incoming HTTP request
|
||||
* @returns {string} The client IP address or 'unknown'
|
||||
*/
|
||||
function getClientIP(request: Request): string {
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
return forwarded.split(',')[0].trim();
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
|
||||
const realIP = request.headers.get('x-real-ip');
|
||||
|
||||
const realIP = request.headers.get("x-real-ip");
|
||||
if (realIP) {
|
||||
return realIP;
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a client IP is currently rate limited
|
||||
*
|
||||
* Uses sliding window rate limiting with configurable window size and max requests.
|
||||
* Creates or resets rate limit records when window expires.
|
||||
*
|
||||
* @param {string} clientIP - The client IP address to check
|
||||
* @returns {boolean} True if client is rate limited, false otherwise
|
||||
*/
|
||||
function isRateLimited(clientIP: string): boolean {
|
||||
const now = Date.now();
|
||||
const key = clientIP;
|
||||
|
||||
|
||||
const record = rateLimitStore.get(key);
|
||||
|
||||
|
||||
if (!record || now > record.resetTime) {
|
||||
// Reset or create new record
|
||||
rateLimitStore.set(key, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (record.count >= RATE_LIMIT_MAX_REQUESTS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
record.count++;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Clean up limit store every minute
|
||||
*/
|
||||
setInterval(() => {
|
||||
@@ -52,51 +75,64 @@ setInterval(() => {
|
||||
}, 60000);
|
||||
|
||||
/**
|
||||
* Hook to add CORS and security headers and to perform rate limiting
|
||||
* @param event Event containing a request
|
||||
* SvelteKit server hook that handles CORS, security headers, and rate limiting
|
||||
*
|
||||
* Functionality includes:
|
||||
* - Rate limiting per client IP with configurable limits
|
||||
* - CORS headers for OPTIONS preflight requests
|
||||
* - Security headers (CSP, X-Frame-Options, HSTS, etc.)
|
||||
* - Special handling for API routes with relaxed CORS
|
||||
*
|
||||
* @param {Object} params - Hook parameters
|
||||
* @param {import('@sveltejs/kit').RequestEvent} params.event - The request event
|
||||
* @param {Function} params.resolve - Function to resolve the request
|
||||
* @returns {Promise<Response>} The response with applied headers and rate limiting
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const { request } = event;
|
||||
const clientIP = getClientIP(request);
|
||||
|
||||
|
||||
if (isRateLimited(clientIP)) {
|
||||
return new Response('Too Many Requests', {
|
||||
return new Response("Too Many Requests", {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': '1',
|
||||
'Content-Type': 'text/plain'
|
||||
"Retry-After": "1",
|
||||
"Content-Type": "text/plain"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*', // TODO: Should be limited to own server and registered webhooks etc.
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
"Access-Control-Allow-Origin": "*", // TODO: Should be limited to own server and registered webhooks etc.
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With",
|
||||
"Access-Control-Max-Age": "86400"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const response = await resolve(event);
|
||||
|
||||
|
||||
// TODO: These has to be rechecked and coordinated with caddy's configuration
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('X-XSS-Protection', '1; mode=block');
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
if (event.url.protocol === 'https:') {
|
||||
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-XSS-Protection", "1; mode=block");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
|
||||
if (event.url.protocol === "https:") {
|
||||
response.headers.set(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const cspDirectives = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' https://unpkg.com", // Allow inline scripts and unpkg CDN (for Swagger)
|
||||
"style-src 'self' 'unsafe-inline' https://unpkg.com",
|
||||
"style-src 'self' 'unsafe-inline' https://unpkg.com",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data: https://unpkg.com",
|
||||
"connect-src 'self'",
|
||||
@@ -107,13 +143,16 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
"frame-ancestors 'none'",
|
||||
"upgrade-insecure-requests"
|
||||
];
|
||||
response.headers.set('Content-Security-Policy', cspDirectives.join('; '));
|
||||
|
||||
if (event.url.pathname.startsWith('/api/')) {
|
||||
response.headers.set('Access-Control-Allow-Origin', '*'); // TODO: Should be limited to own server and registered webhooks etc.
|
||||
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
||||
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
||||
response.headers.set("Content-Security-Policy", cspDirectives.join("; "));
|
||||
|
||||
if (event.url.pathname.startsWith("/api/")) {
|
||||
response.headers.set("Access-Control-Allow-Origin", "*"); // TODO: Should be limited to own server and registered webhooks etc.
|
||||
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
||||
response.headers.set(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization, X-Requested-With"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return response;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
import { env } from "$env/dynamic/private";
|
||||
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
|
||||
|
||||
const client = postgres(env.DATABASE_URL);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pgTable, serial, integer } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, serial, integer } from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable('user', {
|
||||
id: serial('id').primaryKey(),
|
||||
age: integer('age')
|
||||
export const user = pgTable("user", {
|
||||
id: serial("id").primaryKey(),
|
||||
age: integer("age")
|
||||
});
|
||||
|
||||
+43
-37
@@ -1,5 +1,5 @@
|
||||
export type JsonSchema = {
|
||||
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
|
||||
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema;
|
||||
required?: string[];
|
||||
@@ -12,15 +12,18 @@ export type JsonSchema = {
|
||||
|
||||
export interface OpenApiResponse {
|
||||
description: string;
|
||||
content?: Record<string, {
|
||||
schema: JsonSchema;
|
||||
example?: unknown;
|
||||
}>;
|
||||
content?: Record<
|
||||
string,
|
||||
{
|
||||
schema: JsonSchema;
|
||||
example?: unknown;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export interface OpenApiParameter {
|
||||
name: string;
|
||||
in: 'query' | 'path' | 'header';
|
||||
in: "query" | "path" | "header";
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
schema: JsonSchema;
|
||||
@@ -28,10 +31,13 @@ export interface OpenApiParameter {
|
||||
|
||||
export interface OpenApiRequestBody {
|
||||
description?: string;
|
||||
content: Record<string, {
|
||||
schema: JsonSchema;
|
||||
example?: unknown;
|
||||
}>;
|
||||
content: Record<
|
||||
string,
|
||||
{
|
||||
schema: JsonSchema;
|
||||
example?: unknown;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export interface OpenApiOperation {
|
||||
@@ -56,62 +62,62 @@ export function getApiOperations(): Map<string, OpenApiOperation> {
|
||||
|
||||
export function generateOpenApiSpec() {
|
||||
const paths: Record<string, Record<string, OpenApiOperation>> = {};
|
||||
|
||||
|
||||
// Convert registered operations to OpenAPI paths
|
||||
for (const [key, operation] of apiOperationsRegistry.entries()) {
|
||||
const [method, path] = key.split(' ', 2);
|
||||
const [method, path] = key.split(" ", 2);
|
||||
if (!paths[path]) {
|
||||
paths[path] = {};
|
||||
}
|
||||
paths[path][method.toLowerCase()] = operation;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
openapi: '3.0.0',
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: 'Open Reception API',
|
||||
description: 'End-to-end encrypted appointment booking platform',
|
||||
version: '0.0.1',
|
||||
title: "Open Reception API",
|
||||
description: "End-to-end encrypted appointment booking platform",
|
||||
version: "0.0.1",
|
||||
license: {
|
||||
name: 'AGPL-3.0',
|
||||
url: 'https://www.gnu.org/licenses/agpl-3.0.html'
|
||||
name: "AGPL-3.0",
|
||||
url: "https://www.gnu.org/licenses/agpl-3.0.html"
|
||||
}
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api',
|
||||
description: 'API Server'
|
||||
url: "/api",
|
||||
description: "API Server"
|
||||
}
|
||||
],
|
||||
paths,
|
||||
components: {
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: 'string', description: 'Error message' },
|
||||
code: { type: 'string', description: 'Error code' }
|
||||
error: { type: "string", description: "Error message" },
|
||||
code: { type: "string", description: "Error code" }
|
||||
},
|
||||
required: ['error']
|
||||
required: ["error"]
|
||||
},
|
||||
HealthStatus: {
|
||||
type: 'object',
|
||||
type: "object",
|
||||
properties: {
|
||||
core: { type: 'boolean', description: 'Core service status' },
|
||||
database: { type: 'boolean', description: 'Database connectivity status' },
|
||||
memory: { type: 'integer', description: 'Free memory in megabytes' },
|
||||
load: { type: 'number', format: 'float', description: 'Average CPU load' }
|
||||
core: { type: "boolean", description: "Core service status" },
|
||||
database: { type: "boolean", description: "Database connectivity status" },
|
||||
memory: { type: "integer", description: "Free memory in megabytes" },
|
||||
load: { type: "number", format: "float", description: "Average CPU load" }
|
||||
},
|
||||
required: ['core', 'database', 'memory', 'load']
|
||||
required: ["core", "database", "memory", "load"]
|
||||
}
|
||||
}
|
||||
},
|
||||
tags: [
|
||||
{ name: 'Health', description: 'Health check and monitoring endpoints' },
|
||||
{ name: 'Appointments', description: 'Appointment management endpoints' },
|
||||
{ name: 'Clients', description: 'Client management endpoints' },
|
||||
{ name: 'Channels', description: 'Channel management endpoints' },
|
||||
{ name: 'Questionnaires', description: 'Questionnaire management endpoints' }
|
||||
{ name: "Health", description: "Health check and monitoring endpoints" },
|
||||
{ name: "Appointments", description: "Appointment management endpoints" },
|
||||
{ name: "Clients", description: "Client management endpoints" },
|
||||
{ name: "Channels", description: "Channel management endpoints" },
|
||||
{ name: "Questionnaires", description: "Questionnaire management endpoints" }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
export async function GET() {
|
||||
const html = `<!DOCTYPE html>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -53,9 +52,9 @@ export async function GET() {
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
}
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,69 +1,87 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import os from 'os';
|
||||
import { registerOpenAPIRoute } from '$lib/server/openapi';
|
||||
import { json } from "@sveltejs/kit";
|
||||
import { db } from "$lib/server/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import os from "os";
|
||||
import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||
|
||||
registerOpenAPIRoute('/health/services', 'GET', {
|
||||
summary: 'Get service health status',
|
||||
description: 'Returns the health status of core services including database connectivity, memory usage, and CPU load',
|
||||
tags: ['Health'],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Service health status',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/HealthStatus' },
|
||||
example: {
|
||||
core: true,
|
||||
database: true,
|
||||
memory: 2048,
|
||||
load: 0.75
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'429': {
|
||||
description: 'Too Many Requests - Rate limit exceeded',
|
||||
content: {
|
||||
'text/plain': {
|
||||
schema: { type: 'string' },
|
||||
example: 'Too Many Requests'
|
||||
}
|
||||
}
|
||||
},
|
||||
'500': {
|
||||
description: 'Internal Server Error',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
registerOpenAPIRoute("/health/services", "GET", {
|
||||
summary: "Get service health status",
|
||||
description:
|
||||
"Returns the health status of core services including database connectivity, memory usage, and CPU load",
|
||||
tags: ["Health"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Service health status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/HealthStatus" },
|
||||
example: {
|
||||
core: true,
|
||||
database: true,
|
||||
memory: 2048,
|
||||
load: 0.75
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
description: "Too Many Requests - Rate limit exceeded",
|
||||
content: {
|
||||
"text/plain": {
|
||||
schema: { type: "string" },
|
||||
example: "Too Many Requests"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
description: "Internal Server Error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Represents the health status response for core services
|
||||
*/
|
||||
class ServiceHealthResponse {
|
||||
core: boolean = true;
|
||||
database: boolean = false;
|
||||
memory: number = 0;
|
||||
load: number = 0;
|
||||
/** Core service availability status */
|
||||
core: boolean = true;
|
||||
/** Database connectivity status */
|
||||
database: boolean = false;
|
||||
/** Available memory in MB */
|
||||
memory: number = 0;
|
||||
/** Current CPU load average */
|
||||
load: number = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET handler for the health services endpoint
|
||||
*
|
||||
* Performs health checks on core services including:
|
||||
* - Database connectivity test
|
||||
* - System memory availability
|
||||
* - CPU load average
|
||||
*
|
||||
* @returns {Response} JSON response containing service health status
|
||||
*/
|
||||
export async function GET() {
|
||||
const serviceHealth = new ServiceHealthResponse()
|
||||
const serviceHealth = new ServiceHealthResponse();
|
||||
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
serviceHealth.database = true;
|
||||
} catch {
|
||||
serviceHealth.database = false;
|
||||
}
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
serviceHealth.database = true;
|
||||
} catch {
|
||||
serviceHealth.database = false;
|
||||
}
|
||||
|
||||
serviceHealth.memory = Math.round(os.freemem() / 1024 / 1024);
|
||||
serviceHealth.memory = Math.round(os.freemem() / 1024 / 1024);
|
||||
|
||||
const loadAvg = os.loadavg();
|
||||
serviceHealth.load = Math.round(loadAvg[0] * 100) / 100;
|
||||
const loadAvg = os.loadavg();
|
||||
serviceHealth.load = Math.round(loadAvg[0] * 100) / 100;
|
||||
|
||||
return json(serviceHealth);
|
||||
}
|
||||
return json(serviceHealth);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GET } from "./+server.js";
|
||||
|
||||
// Mock the database module
|
||||
vi.mock("$lib/server/db", () => ({
|
||||
db: {
|
||||
execute: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock the OpenAPI registration
|
||||
vi.mock("$lib/server/openapi", () => ({
|
||||
registerOpenAPIRoute: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock os module
|
||||
vi.mock("os", () => ({
|
||||
default: {
|
||||
freemem: vi.fn(),
|
||||
loadavg: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock SvelteKit json helper
|
||||
vi.mock("@sveltejs/kit", () => ({
|
||||
json: vi.fn((data) => ({
|
||||
json: () => Promise.resolve(data),
|
||||
data
|
||||
}))
|
||||
}));
|
||||
|
||||
describe("/api/health/services", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return healthy status when database connection succeeds", async () => {
|
||||
const { db } = await import("$lib/server/db");
|
||||
const os = (await import("os")).default;
|
||||
|
||||
// Mock successful database connection
|
||||
vi.mocked(db.execute).mockResolvedValue([] as any);
|
||||
|
||||
// Mock system metrics
|
||||
vi.mocked(os.freemem).mockReturnValue(2048 * 1024 * 1024); // 2048 MB in bytes
|
||||
vi.mocked(os.loadavg).mockReturnValue([0.75, 0.5, 0.3]);
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
core: true,
|
||||
database: true,
|
||||
memory: 2048,
|
||||
load: 0.75
|
||||
});
|
||||
});
|
||||
|
||||
it("should return unhealthy database status when connection fails", async () => {
|
||||
const { db } = await import("$lib/server/db");
|
||||
const os = (await import("os")).default;
|
||||
|
||||
// Mock failed database connection
|
||||
vi.mocked(db.execute).mockRejectedValue(new Error("Connection failed"));
|
||||
|
||||
// Mock system metrics
|
||||
vi.mocked(os.freemem).mockReturnValue(1024 * 1024 * 1024); // 1024 MB in bytes
|
||||
vi.mocked(os.loadavg).mockReturnValue([1.25, 1.0, 0.8]);
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data).toEqual({
|
||||
core: true,
|
||||
database: false,
|
||||
memory: 1024,
|
||||
load: 1.25
|
||||
});
|
||||
});
|
||||
|
||||
it("should calculate memory in MB correctly", async () => {
|
||||
const { db } = await import("$lib/server/db");
|
||||
const os = (await import("os")).default;
|
||||
|
||||
vi.mocked(db.execute).mockResolvedValue([] as any);
|
||||
|
||||
// Test different memory values
|
||||
vi.mocked(os.freemem).mockReturnValue(512 * 1024 * 1024); // 512 MB in bytes
|
||||
vi.mocked(os.loadavg).mockReturnValue([0.1, 0.1, 0.1]);
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.memory).toBe(512);
|
||||
});
|
||||
|
||||
it("should round load average to 2 decimal places", async () => {
|
||||
const { db } = await import("$lib/server/db");
|
||||
const os = (await import("os")).default;
|
||||
|
||||
vi.mocked(db.execute).mockResolvedValue([] as any);
|
||||
vi.mocked(os.freemem).mockReturnValue(1024 * 1024 * 1024);
|
||||
|
||||
// Test load average rounding
|
||||
vi.mocked(os.loadavg).mockReturnValue([1.23456, 0.5, 0.3]);
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.load).toBe(1.23);
|
||||
});
|
||||
|
||||
it("should always return core as true", async () => {
|
||||
const { db } = await import("$lib/server/db");
|
||||
const os = (await import("os")).default;
|
||||
|
||||
vi.mocked(db.execute).mockRejectedValue(new Error("Database error"));
|
||||
vi.mocked(os.freemem).mockReturnValue(0);
|
||||
vi.mocked(os.loadavg).mockReturnValue([10.0, 5.0, 2.0]);
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.core).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { generateOpenApiSpec } from '$lib/server/openapi.js';
|
||||
import { json } from "@sveltejs/kit";
|
||||
import { generateOpenApiSpec } from "$lib/server/openapi.js";
|
||||
|
||||
// ATTENTION: All routes need to be imported here to make sure they're added to the OpenAPI spec
|
||||
import '../health/services/+server.js';
|
||||
import "../health/services/+server.js";
|
||||
|
||||
export async function GET() {
|
||||
const spec = generateOpenApiSpec();
|
||||
return json(spec);
|
||||
}
|
||||
const spec = generateOpenApiSpec();
|
||||
return json(spec);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import Page from './+page.svelte';
|
||||
import { describe, test, expect } from "vitest";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render, screen } from "@testing-library/svelte";
|
||||
import Page from "./+page.svelte";
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
test('should render h1', () => {
|
||||
describe("/+page.svelte", () => {
|
||||
test("should render h1", () => {
|
||||
render(Page);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
import adapter from "@sveltejs/adapter-auto";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
|
||||
+15
-15
@@ -1,31 +1,31 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { svelteTesting } from '@testing-library/svelte/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { svelteTesting } from "@testing-library/svelte/vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
extends: "./vite.config.ts",
|
||||
plugins: [svelteTesting()],
|
||||
test: {
|
||||
name: 'client',
|
||||
environment: 'jsdom',
|
||||
name: "client",
|
||||
environment: "jsdom",
|
||||
clearMocks: true,
|
||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/lib/server/**'],
|
||||
setupFiles: ['./vitest-setup-client.ts']
|
||||
include: ["src/**/*.svelte.{test,spec}.{js,ts}"],
|
||||
exclude: ["src/lib/server/**"],
|
||||
setupFiles: ["./vitest-setup-client.ts"]
|
||||
}
|
||||
},
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
extends: "./vite.config.ts",
|
||||
test: {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||
name: "server",
|
||||
environment: "node",
|
||||
include: ["src/**/*.{test,spec}.{js,ts}"],
|
||||
exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { vi } from 'vitest';
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// required for svelte5 + jsdom as jsdom does not support matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
|
||||
Reference in New Issue
Block a user