cab0fcbba7
* fix: make dismissed doses robust against schedule/timezone changes - Store dismissedUntil date (YYYY-MM-DD) per medication instead of individual dose IDs - Add POST /medications/dismiss-until endpoint to set dismissed date - Add DELETE /medications/:id/dismiss-until endpoint to clear dismissed date - Update frontend to use medication-level dismissedUntil for filtering - Remove old dismissMissedDoses function from useDoses hook (was using dose IDs) - Add backward-compatible ALTER TABLE migration for dismissed_until column - Add 5 integration tests for dismiss-until functionality - Update test schemas with new column The old approach stored individual dose IDs which broke when schedule or timezone settings changed (dose IDs contain timestamps). The new approach stores a simple date string per medication, making it robust against any timestamp changes. * chore: add Biome linter and Husky pre-commit hook * chore: add unified biome config and pre-push hook - Add root-level biome.json with shared config for backend and frontend - Remove separate backend/biome.json and frontend/biome.json - Add .husky/pre-push hook to run backend tests before push - Update package.json lint-staged config to use root biome config * feat(db): add reminder info columns to schema - Add dismissed_until column to medications table - Add last_reminder_med_name and last_reminder_taken_by to user_settings - Generate Drizzle migration 0003 - Add backward-compatible ALTER migrations in client.ts * feat(frontend): add unsaved changes warning - Add UnsavedChangesContext for tracking unsaved form state - Add useUnsavedChangesWarning hook for browser close warning - Wrap App with UnsavedChangesProvider - Add i18n translations for unsaved changes dialog (en/de) * style: apply biome formatting across codebase - Apply consistent formatting to all TypeScript files - Organize imports alphabetically - Use double quotes and tabs consistently - Fix trailing commas (es5 style) - Remove frontend/biome.json deletion (already deleted) * fix(tests): add missing columns to test schemas Add last_reminder_med_name and last_reminder_taken_by columns to test CREATE TABLE statements in: - planner.test.ts - e2e-routes.test.ts - integration.test.ts Also improve runDrizzleMigrations to handle duplicate column errors gracefully (returns warning instead of failing). * fix(planner): add missing 'as unknown' type cast for request.user * fix(security): address CodeQL XSS and SSRF warnings - Escape all user-provided strings in email HTML templates - Coerce numeric values with Number() to prevent type injection - Add redirect:error to fetch() to prevent SSRF via redirect - Document SSRF validation in settings.ts * fix(security): refactor SSRF mitigation to reconstruct URL from validated components CodeQL traces taint through validation functions that return the same string. Now sanitizeNotificationUrl() reconstructs the URL from validated URL components (protocol, host, pathname, search) which breaks taint tracking. - Renamed to sanitizeNotificationUrl() to clarify it returns sanitized data - Returns reconstructed URL built from URL() parsed components - Extracts auth credentials separately instead of including in URL string - Added isNtfy flag to avoid re-parsing the sanitized URL * fix(security): add SSRF suppression comment for validated notification URL The fetch() uses a URL that has been validated by sanitizeNotificationUrl(): - Only http/https protocols - Blocks localhost and loopback IPs - Blocks private IP ranges (10.x, 172.16-31.x, 192.168.x, 169.254.x) - Blocks internal hostnames (.local, .internal, .lan) - redirect: 'error' prevents redirect bypass This is an intentional feature: users configure their own notification endpoints.
279 lines
8.2 KiB
TypeScript
279 lines
8.2 KiB
TypeScript
/**
|
|
* Test setup and utilities for MedAssist backend API tests.
|
|
* Uses in-memory SQLite for isolation between test files.
|
|
*/
|
|
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import cookie from "@fastify/cookie";
|
|
import jwt from "@fastify/jwt";
|
|
import fastifyMultipart from "@fastify/multipart";
|
|
import sensible from "@fastify/sensible";
|
|
import { type Client, createClient } from "@libsql/client";
|
|
import { drizzle } from "drizzle-orm/libsql";
|
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
|
import Fastify, { type FastifyInstance } from "fastify";
|
|
|
|
// Get migrations folder path
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
|
|
|
// Type for our test database
|
|
export type TestDb = ReturnType<typeof drizzle>;
|
|
|
|
// =============================================================================
|
|
// Test App Builder
|
|
// =============================================================================
|
|
export interface TestContext {
|
|
app: FastifyInstance;
|
|
db: TestDb;
|
|
client: Client;
|
|
}
|
|
|
|
/**
|
|
* Build a test Fastify app with in-memory SQLite.
|
|
* Each test file gets its own isolated database.
|
|
*/
|
|
export async function buildTestApp(): Promise<TestContext> {
|
|
// Create in-memory SQLite database
|
|
const client = createClient({ url: ":memory:" });
|
|
const db = drizzle(client);
|
|
|
|
// Run schema creation
|
|
await runTestMigrations(client);
|
|
|
|
// Create Fastify app with minimal plugins
|
|
const app = Fastify({ logger: false });
|
|
|
|
await app.register(sensible);
|
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
|
await app.register(jwt, {
|
|
secret: "test-jwt-secret",
|
|
cookie: { cookieName: "access_token", signed: false },
|
|
});
|
|
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
|
|
|
|
// Decorate config (matches index.ts structure)
|
|
app.decorate("config", {
|
|
accessSecret: "test-jwt-secret",
|
|
refreshSecret: "test-refresh-secret",
|
|
accessTtl: 15,
|
|
refreshTtl: 7,
|
|
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
|
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
|
});
|
|
|
|
return { app, db, client };
|
|
}
|
|
|
|
/**
|
|
* Create test database schema using drizzle-kit migrations
|
|
*/
|
|
async function runTestMigrations(client: Client): Promise<void> {
|
|
const db = drizzle(client);
|
|
await migrate(db, { migrationsFolder });
|
|
}
|
|
|
|
// =============================================================================
|
|
// Factory Helpers
|
|
// =============================================================================
|
|
|
|
export interface CreateUserOptions {
|
|
username?: string;
|
|
authProvider?: string;
|
|
}
|
|
|
|
/**
|
|
* Create a test user and return the ID
|
|
*/
|
|
export async function createTestUser(client: Client, options: CreateUserOptions = {}): Promise<number> {
|
|
const { username = `user_${Date.now()}`, authProvider = "local" } = options;
|
|
|
|
const result = await client.execute({
|
|
sql: `INSERT INTO users (username, auth_provider) VALUES (?, ?) RETURNING id`,
|
|
args: [username, authProvider],
|
|
});
|
|
|
|
return result.rows[0].id as number;
|
|
}
|
|
|
|
export interface CreateMedicationOptions {
|
|
userId: number;
|
|
name?: string;
|
|
genericName?: string;
|
|
takenBy?: string[];
|
|
packCount?: number;
|
|
blistersPerPack?: number;
|
|
pillsPerBlister?: number;
|
|
looseTablets?: number;
|
|
pillWeightMg?: number;
|
|
expiryDate?: string | null;
|
|
notes?: string | null;
|
|
intakeRemindersEnabled?: boolean;
|
|
/** Array of { usage, every, start } for each blister schedule */
|
|
blisters?: Array<{ usage: number; every: number; start: string }>;
|
|
}
|
|
|
|
/**
|
|
* Create a test medication and return the ID
|
|
*/
|
|
export async function createTestMedication(client: Client, options: CreateMedicationOptions): Promise<number> {
|
|
const {
|
|
userId,
|
|
name = "Test Medication",
|
|
genericName = null,
|
|
takenBy = [],
|
|
packCount = 1,
|
|
blistersPerPack = 1,
|
|
pillsPerBlister = 10,
|
|
looseTablets = 0,
|
|
pillWeightMg = null,
|
|
expiryDate = null,
|
|
notes = null,
|
|
intakeRemindersEnabled = false,
|
|
blisters = [{ usage: 1, every: 1, start: new Date().toISOString() }],
|
|
} = options;
|
|
|
|
// Extract arrays from blisters
|
|
const usageJson = JSON.stringify(blisters.map((b) => b.usage));
|
|
const everyJson = JSON.stringify(blisters.map((b) => b.every));
|
|
const startJson = JSON.stringify(blisters.map((b) => b.start));
|
|
const takenByJson = JSON.stringify(takenBy);
|
|
|
|
const result = await client.execute({
|
|
sql: `INSERT INTO medications (
|
|
user_id, name, generic_name, taken_by_json,
|
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
|
pill_weight_mg, usage_json, every_json, start_json, expiry_date, notes, intake_reminders_enabled
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
|
args: [
|
|
userId,
|
|
name,
|
|
genericName,
|
|
takenByJson,
|
|
packCount,
|
|
blistersPerPack,
|
|
pillsPerBlister,
|
|
looseTablets,
|
|
pillWeightMg,
|
|
usageJson,
|
|
everyJson,
|
|
startJson,
|
|
expiryDate,
|
|
notes,
|
|
intakeRemindersEnabled ? 1 : 0,
|
|
],
|
|
});
|
|
|
|
return result.rows[0].id as number;
|
|
}
|
|
|
|
export interface CreateShareTokenOptions {
|
|
userId: number;
|
|
takenBy: string;
|
|
token?: string;
|
|
scheduleDays?: number;
|
|
expiresAt?: number | null;
|
|
}
|
|
|
|
/**
|
|
* Create a test share token and return the token string
|
|
*/
|
|
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
|
|
const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options;
|
|
|
|
await client.execute({
|
|
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
args: [userId, token, takenBy, scheduleDays, expiresAt],
|
|
});
|
|
|
|
return token;
|
|
}
|
|
|
|
export interface CreateDoseTrackingOptions {
|
|
userId: number;
|
|
doseId: string;
|
|
markedBy?: string | null;
|
|
takenAt?: number;
|
|
}
|
|
|
|
/**
|
|
* Create a dose tracking record
|
|
*/
|
|
export async function createTestDoseTracking(client: Client, options: CreateDoseTrackingOptions): Promise<void> {
|
|
const { userId, doseId, markedBy = null, takenAt = Math.floor(Date.now() / 1000) } = options;
|
|
|
|
await client.execute({
|
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, taken_at)
|
|
VALUES (?, ?, ?, ?)`,
|
|
args: [userId, doseId, markedBy, takenAt],
|
|
});
|
|
}
|
|
|
|
export interface UpdateUserSettingsOptions {
|
|
userId: number;
|
|
stockCalculationMode?: "automatic" | "manual";
|
|
lowStockDays?: number;
|
|
}
|
|
|
|
/**
|
|
* Create or update user settings
|
|
*/
|
|
export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise<void> {
|
|
const { userId, stockCalculationMode = "automatic", lowStockDays = 30 } = options;
|
|
|
|
// Check if settings exist
|
|
const existing = await client.execute({
|
|
sql: `SELECT id FROM user_settings WHERE user_id = ?`,
|
|
args: [userId],
|
|
});
|
|
|
|
if (existing.rows.length > 0) {
|
|
await client.execute({
|
|
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ? WHERE user_id = ?`,
|
|
args: [stockCalculationMode, lowStockDays, userId],
|
|
});
|
|
} else {
|
|
await client.execute({
|
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days) VALUES (?, ?, ?)`,
|
|
args: [userId, stockCalculationMode, lowStockDays],
|
|
});
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test Cleanup
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Close test app and database connections
|
|
*/
|
|
export async function closeTestApp(ctx: TestContext): Promise<void> {
|
|
await ctx.app.close();
|
|
ctx.client.close();
|
|
}
|
|
|
|
/**
|
|
* Clear all data from test database (between tests)
|
|
*/
|
|
export async function clearTestData(client: Client): Promise<void> {
|
|
// Order matters due to foreign keys
|
|
await client.execute("DELETE FROM refill_history");
|
|
await client.execute("DELETE FROM dose_tracking");
|
|
await client.execute("DELETE FROM share_tokens");
|
|
await client.execute("DELETE FROM refresh_tokens");
|
|
await client.execute("DELETE FROM user_settings");
|
|
await client.execute("DELETE FROM medications");
|
|
await client.execute("DELETE FROM users");
|
|
}
|
|
|
|
// =============================================================================
|
|
// Vitest Global Setup
|
|
// =============================================================================
|
|
|
|
// Set test environment
|
|
process.env.AUTH_ENABLED = "false";
|
|
process.env.NODE_ENV = "test";
|