diff --git a/.prettierrc b/.prettierrc index 7ebb855..1e5915a 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ { "useTabs": true, - "singleQuote": true, + "singleQuote": false, "trailingComma": "none", "printWidth": 100, "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], diff --git a/drizzle.config.ts b/drizzle.config.ts index d57e081..f42374f 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -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 diff --git a/e2e/demo.test.ts b/e2e/demo.test.ts index 9985ce1..1f5a374 100644 --- a/e2e/demo.test.ts +++ b/e2e/demo.test.ts @@ -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(); }); diff --git a/eslint.config.js b/eslint.config.js index ef07d32..b41ad17 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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 } diff --git a/playwright.config.ts b/playwright.config.ts index f6c81af..6449fa1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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" }); diff --git a/src/app.css b/src/app.css index 1c4d2a8..5b46619 100644 --- a/src/app.css +++ b/src/app.css @@ -1,2 +1,2 @@ -@import 'tailwindcss'; +@import "tailwindcss"; @plugin '@tailwindcss/typography'; diff --git a/src/demo.spec.ts b/src/demo.spec.ts index e07cbbd..808401e 100644 --- a/src/demo.spec.ts +++ b/src/demo.spec.ts @@ -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); }); }); diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts new file mode 100644 index 0000000..afa4be2 --- /dev/null +++ b/src/hooks.server.test.ts @@ -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(); + }); + }); +}); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e89b06e..77314f2 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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(); +/** 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} 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; -}; \ No newline at end of file +}; diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index fb8b355..f915985 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index b256205..bfe8980 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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") }); diff --git a/src/lib/server/openapi.ts b/src/lib/server/openapi.ts index 31aa141..30f9342 100644 --- a/src/lib/server/openapi.ts +++ b/src/lib/server/openapi.ts @@ -1,5 +1,5 @@ export type JsonSchema = { - type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object'; + type?: "string" | "number" | "integer" | "boolean" | "array" | "object"; properties?: Record; items?: JsonSchema; required?: string[]; @@ -12,15 +12,18 @@ export type JsonSchema = { export interface OpenApiResponse { description: string; - content?: Record; + 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; + content: Record< + string, + { + schema: JsonSchema; + example?: unknown; + } + >; } export interface OpenApiOperation { @@ -56,62 +62,62 @@ export function getApiOperations(): Map { export function generateOpenApiSpec() { const paths: Record> = {}; - + // 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" } ] }; -} \ No newline at end of file +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b93e9ba..2458f08 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,5 @@ diff --git a/src/routes/api/docs/+server.ts b/src/routes/api/docs/+server.ts index 3da17ad..889c8f4 100644 --- a/src/routes/api/docs/+server.ts +++ b/src/routes/api/docs/+server.ts @@ -1,6 +1,5 @@ - export async function GET() { - const html = ` + const html = ` @@ -53,9 +52,9 @@ export async function GET() { `; - return new Response(html, { - headers: { - 'Content-Type': 'text/html' - } - }); -} \ No newline at end of file + return new Response(html, { + headers: { + "Content-Type": "text/html" + } + }); +} diff --git a/src/routes/api/health/services/+server.ts b/src/routes/api/health/services/+server.ts index 9d136fc..273d2ad 100644 --- a/src/routes/api/health/services/+server.ts +++ b/src/routes/api/health/services/+server.ts @@ -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); -} \ No newline at end of file + return json(serviceHealth); +} diff --git a/src/routes/api/health/services/server.test.ts b/src/routes/api/health/services/server.test.ts new file mode 100644 index 0000000..9d9cfda --- /dev/null +++ b/src/routes/api/health/services/server.test.ts @@ -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); + }); +}); diff --git a/src/routes/api/openapi.json/+server.ts b/src/routes/api/openapi.json/+server.ts index c203418..5abd713 100644 --- a/src/routes/api/openapi.json/+server.ts +++ b/src/routes/api/openapi.json/+server.ts @@ -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); -} \ No newline at end of file + const spec = generateOpenApiSpec(); + return json(spec); +} diff --git a/src/routes/page.svelte.test.ts b/src/routes/page.svelte.test.ts index a110662..0161070 100644 --- a/src/routes/page.svelte.test.ts +++ b/src/routes/page.svelte.test.ts @@ -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(); }); }); diff --git a/svelte.config.js b/svelte.config.js index 1295460..c4bd540 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -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 = { diff --git a/vite.config.ts b/vite.config.ts index 7e61d35..f1fe658 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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}"] } } ] diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index ccc8a70..ecda271 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.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) => ({