import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import cookie from "@fastify/cookie"; import sensible from "@fastify/sensible"; import { migrate } from "drizzle-orm/libsql/migrator"; import Fastify, { type FastifyInstance } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runAlterMigrations } from "../db/db-utils.js"; import { jwtPlugin } from "../plugins/jwt.js"; import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; const { testClient, testDb, mockedEnv, nodemailerSendMail } = vi.hoisted(() => { const { createClient } = require("@libsql/client"); const { drizzle } = require("drizzle-orm/libsql"); const client = createClient({ url: ":memory:" }); const db = drizzle(client); return { testClient: client, testDb: db, mockedEnv: { AUTH_ENABLED: true, REGISTRATION_ENABLED: true, FORM_LOGIN_ENABLED: true, OIDC_ENABLED: false, OIDC_PROVIDER_NAME: "SSO", NODE_ENV: "test", LOG_LEVEL: "silent", PORT: 3000, CORS_ORIGINS: "*", JWT_SECRET: "test-jwt-secret", REFRESH_SECRET: "test-refresh-secret", COOKIE_SECRET: "test-cookie-secret", ACCESS_TOKEN_TTL_MINUTES: 15, REFRESH_TOKEN_TTL_DAYS: 7, OPENAPI_DOCS_ENABLED: false, }, nodemailerSendMail: vi.fn(), }; }); vi.mock("../db/client.js", () => ({ db: testDb, migrationsReady: Promise.resolve(), })); vi.mock("../plugins/env.js", () => ({ env: mockedEnv })); vi.mock("nodemailer", () => ({ default: { createTransport: () => ({ sendMail: nodemailerSendMail, }), }, })); const { settingsRoutes } = await import("../routes/settings.js"); const { apiKeyRoutes } = await import("../routes/api-keys.js"); const { hashApiKeyToken } = await import("../plugins/auth.js"); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const migrationsFolder = resolve(__dirname, "../../drizzle"); async function clearTables() { await testClient.execute("DELETE FROM api_keys"); await testClient.execute("DELETE FROM refresh_tokens"); await testClient.execute("DELETE FROM user_settings"); await testClient.execute("DELETE FROM users"); } async function createUser(username: string) { const result = await testClient.execute({ sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id", args: [username], }); return Number(result.rows[0].id); } async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) { const token = await app.jwt.sign({ sub: userId, username }); return `access_token=${token}`; } async function insertApiKey(options: { userId: number; token: string; scope?: "read" | "write"; isActive?: boolean; expiresAt?: Date | null; }) { const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null; const result = await testClient.execute({ sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`, args: [ options.userId, "Seeded Key", hashApiKeyToken(options.token), `${options.token.slice(0, 12)}...`, options.scope ?? "write", options.isActive === false ? 0 : 1, expiresAtValue, ], }); return Number(result.rows[0].id); } describe("Settings and API key security contracts", () => { let app: FastifyInstance; beforeAll(async () => { await migrate(testDb, { migrationsFolder }); await runAlterMigrations(testClient); app = Fastify({ logger: false, ajv: documentationSchemaAjv }); await app.register(sensible); await app.register(cookie, { secret: "test-cookie-secret" }); await app.register(jwtPlugin, { secret: "test-jwt-secret", cookie: { cookieName: "access_token", signed: false }, }); await app.register(settingsRoutes); await app.register(apiKeyRoutes); await app.ready(); }); afterAll(async () => { await app.close(); testClient.close(); }); beforeEach(async () => { vi.clearAllMocks(); await clearTables(); delete process.env.SMTP_HOST; delete process.env.SMTP_USER; delete process.env.SMTP_TOKEN; delete process.env.SMTP_PASS; delete process.env.SMTP_FROM; delete process.env.SMTP_PORT; delete process.env.SMTP_SECURE; }); it("rejects GET /settings without authentication when auth is enabled", async () => { const response = await app.inject({ method: "GET", url: "/settings" }); expect(response.statusCode).toBe(401); expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" }); }); it("returns settings defaults for an authenticated session cookie", async () => { const userId = await createUser("settings-session-user"); const response = await app.inject({ method: "GET", url: "/settings", headers: { cookie: await buildSessionCookie(app, userId, "settings-session-user") }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual( expect.objectContaining({ emailEnabled: false, language: "en", stockCalculationMode: "automatic", }) ); }); it("allows GET /settings with a read-only API key", async () => { const userId = await createUser("settings-read-user"); process.env.SMTP_HOST = "smtp.example.com"; process.env.SMTP_PORT = "2525"; const apiToken = "ma_read_only_valid_token_123456789"; await insertApiKey({ userId, token: apiToken, scope: "read" }); const response = await app.inject({ method: "GET", url: "/settings", headers: { authorization: `Bearer ${apiToken}` }, }); expect(response.statusCode).toBe(200); expect(response.json()).toEqual( expect.objectContaining({ smtpHost: "smtp.example.com", smtpPort: 2525, }) ); }); it("rejects PUT /settings with a read-only API key", async () => { const userId = await createUser("settings-read-mutation-user"); const apiToken = "ma_read_only_mutation_token_123456789"; await insertApiKey({ userId, token: apiToken, scope: "read" }); const response = await app.inject({ method: "PUT", url: "/settings", headers: { authorization: `Bearer ${apiToken}` }, payload: { emailEnabled: false, notificationEmail: "", reminderDaysBefore: 7, repeatDailyReminders: false, lowStockDays: 30, normalStockDays: 90, highStockDays: 180, shoutrrrEnabled: false, shoutrrrUrl: "", emailStockReminders: true, emailIntakeReminders: true, emailPrescriptionReminders: true, shoutrrrStockReminders: true, shoutrrrIntakeReminders: true, shoutrrrPrescriptionReminders: true, skipRemindersForTakenDoses: false, repeatRemindersEnabled: false, reminderRepeatIntervalMinutes: 30, maxNaggingReminders: 5, language: "en", stockCalculationMode: "automatic", upcomingTodayOnly: false, shareScheduleTodayOnly: false, swapDashboardMainSections: false, }, }); expect(response.statusCode).toBe(403); expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" }); }); it("rejects invalid API key bearer tokens for GET /settings", async () => { const response = await app.inject({ method: "GET", url: "/settings", headers: { authorization: "Bearer definitely-not-a-medassist-key" }, }); expect(response.statusCode).toBe(401); expect(response.json()).toMatchObject({ code: "INVALID_API_KEY" }); }); it("rejects expired API keys for GET /settings", async () => { const userId = await createUser("settings-expired-key-user"); const apiToken = "ma_expired_token_for_settings_123456789"; await insertApiKey({ userId, token: apiToken, scope: "read", expiresAt: new Date(Date.now() - 60_000), }); const response = await app.inject({ method: "GET", url: "/settings", headers: { authorization: `Bearer ${apiToken}` }, }); expect(response.statusCode).toBe(401); expect(response.json()).toMatchObject({ code: "API_KEY_EXPIRED" }); }); it("rotates API keys and does not leak raw tokens from the list endpoint", async () => { const userId = await createUser("api-key-session-user"); const cookieHeader = await buildSessionCookie(app, userId, "api-key-session-user"); const firstCreate = await app.inject({ method: "POST", url: "/auth/api-keys", headers: { cookie: cookieHeader }, payload: { name: "Primary key", scope: "write", expiresInDays: 30 }, }); expect(firstCreate.statusCode).toBe(201); const firstBody = firstCreate.json(); expect(firstBody.token).toMatch(/^ma_/); const secondCreate = await app.inject({ method: "POST", url: "/auth/api-keys", headers: { cookie: cookieHeader }, payload: { name: "Rotated key", scope: "write", expiresInDays: 30 }, }); expect(secondCreate.statusCode).toBe(201); const secondBody = secondCreate.json(); const listResponse = await app.inject({ method: "GET", url: "/auth/api-keys", headers: { cookie: cookieHeader }, }); expect(listResponse.statusCode).toBe(200); expect(listResponse.body).not.toContain(firstBody.token); expect(listResponse.body).not.toContain(secondBody.token); expect(listResponse.body).not.toContain("keyHash"); expect(listResponse.json().keys).toHaveLength(2); const dbState = await testClient.execute({ sql: "SELECT name, is_active FROM api_keys WHERE user_id = ? ORDER BY id ASC", args: [userId], }); expect(dbState.rows).toEqual([ expect.objectContaining({ name: "Primary key", is_active: 0 }), expect.objectContaining({ name: "Rotated key", is_active: 1 }), ]); }); it("rejects API key rotation when authenticated with a read-only API key", async () => { const userId = await createUser("api-key-readonly-rotate-user"); const readOnlyToken = "ma_readonly_rotation_denied_123456789"; await insertApiKey({ userId, token: readOnlyToken, scope: "read" }); const response = await app.inject({ method: "POST", url: "/auth/api-keys", headers: { authorization: `Bearer ${readOnlyToken}` }, payload: { name: "Blocked rotation", scope: "write", expiresInDays: 30 }, }); expect(response.statusCode).toBe(403); expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" }); }); it("returns 404 when deleting an API key owned by a different user", async () => { const ownerUserId = await createUser("api-key-owner"); const otherUserId = await createUser("api-key-other-user"); const otherCookieHeader = await buildSessionCookie(app, otherUserId, "api-key-other-user"); const keyId = await insertApiKey({ userId: ownerUserId, token: "ma_write_owner_token_123456789", scope: "write", }); const response = await app.inject({ method: "DELETE", url: `/auth/api-keys/${keyId}`, headers: { cookie: otherCookieHeader }, }); expect(response.statusCode).toBe(404); expect(response.json()).toMatchObject({ code: "API_KEY_NOT_FOUND" }); }); it("maps SMTP recipient rejection to HTTP 400 instead of a generic 500", async () => { const userId = await createUser("settings-email-recipient-user"); process.env.SMTP_HOST = "smtp.example.com"; process.env.SMTP_USER = "mailer@example.com"; process.env.SMTP_PASS = "secret"; nodemailerSendMail.mockResolvedValue({ accepted: [], rejected: ["missing@example.com"], response: "550 5.1.1 recipient address rejected", }); const response = await app.inject({ method: "POST", url: "/settings/test-email", headers: { cookie: await buildSessionCookie(app, userId, "settings-email-recipient-user") }, payload: { email: "missing@example.com" }, }); expect(response.statusCode).toBe(400); expect(response.json()).toMatchObject({ code: "EMAIL_RECIPIENT_REJECTED" }); }); it("maps missing SMTP acceptance to HTTP 502 for test email", async () => { const userId = await createUser("settings-email-unconfirmed-user"); process.env.SMTP_HOST = "smtp.example.com"; process.env.SMTP_USER = "mailer@example.com"; process.env.SMTP_PASS = "secret"; nodemailerSendMail.mockResolvedValue({ accepted: [], rejected: [], response: "250 queued without explicit acceptance", }); const response = await app.inject({ method: "POST", url: "/settings/test-email", headers: { cookie: await buildSessionCookie(app, userId, "settings-email-unconfirmed-user") }, payload: { email: "person@example.com" }, }); expect(response.statusCode).toBe(502); expect(response.json()).toMatchObject({ code: "SMTP_DELIVERY_UNCONFIRMED" }); }); });