release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
1 parent b01822be48
commit 62eb431d66
372 files changed
+25178 -8561

No files matched your search

@@ -0,0 +1,300 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findUnique: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
vi.mock("@/lib/telegram", () => ({
setTelegramWebhook: vi.fn().mockResolvedValue(true),
deleteTelegramWebhook: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/notifications/senders/email-config", () => ({
isEmailConfigured: vi.fn(() => true),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT as putWebhook } from "../webhook/route";
import { PUT as putNtfy } from "../ntfy/route";
import { PUT as putEmail } from "../email/route";
import { PUT as putTelegram } from "../telegram/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { setTelegramWebhook } from "@/lib/telegram";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const routes = {
webhook: putWebhook,
ntfy: putNtfy,
email: putEmail,
telegram: putTelegram,
};
type PutRoute = (request: Request) => Promise<Response>;
function put(channel: keyof typeof routes, body: unknown) {
return (routes[channel] as PutRoute)(
new Request(`http://localhost/api/settings/${channel}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("notification channel enable-only routes", () => {
it.each(Object.keys(routes) as Array<keyof typeof routes>)(
"%s rejects unauthenticated toggle writes",
async (channel) => {
vi.mocked(getSession).mockResolvedValue(null);
const response = await put(channel, { enabled: false });
expect(response.status).toBe(401);
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
},
);
it("updates only the owned webhook enabled field", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"url":"https://saved.example/hook","headerName":"Authorization","headerValue":"secret"}',
} as never);
const response = await put("webhook", { enabled: true });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { saved: true },
error: null,
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "WEBHOOK" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("updates only the owned ntfy enabled field", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}',
} as never);
const response = await put("ntfy", { enabled: true });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { saved: true },
error: null,
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "NTFY" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("updates only the owned email enabled field", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: 'encrypted:{"recipient":"saved@example.com"}',
} as never);
const response = await put("email", { enabled: true });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { saved: true },
error: null,
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "EMAIL" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("updates only Telegram enabled fields and leaves stored credentials untouched", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: "encrypted:saved-token",
telegramChatId: "saved-chat",
telegramEnabled: true,
} as never);
const response = await put("telegram", { enabled: false });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { updated: true },
error: null,
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { telegramEnabled: false },
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "TELEGRAM" },
data: { enabled: false },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
expect(prisma.notificationChannel.deleteMany).not.toHaveBeenCalled();
});
it("registers Telegram on enable without rewriting stored credentials", async () => {
vi.stubEnv("APP_URL", "https://health.example");
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: "encrypted:saved-token",
telegramChatId: "saved-chat",
telegramEnabled: false,
} as never);
const response = await put("telegram", { enabled: true });
expect(response.status).toBe(200);
expect(setTelegramWebhook).toHaveBeenCalledWith(
"saved-token",
"https://health.example/api/telegram/webhook",
undefined,
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { telegramEnabled: true },
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "TELEGRAM" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it.each([
[
"webhook",
{
url: "https://saved.example/hook",
headerName: "X-Test",
headerValue: "secret",
enabled: false,
},
],
[
"ntfy",
{
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
authToken: "secret",
enabled: false,
},
],
["email", { recipient: "saved@example.com", enabled: false }],
] as const)(
"keeps the existing full %s save contract",
async (channel, body) => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
const response = await put(channel, body);
expect(response.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledOnce();
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
},
);
it("keeps the existing full Telegram save contract", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: null,
telegramChatId: null,
telegramEnabled: false,
} as never);
const response = await put("telegram", {
botToken: "new-token",
chatId: "new-chat",
enabled: false,
});
expect(response.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
telegramEnabled: false,
telegramBotToken: "encrypted:new-token",
telegramChatId: "new-chat",
},
});
expect(prisma.notificationChannel.upsert).toHaveBeenCalledOnce();
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
});
it.each(["webhook", "ntfy", "email"] as const)(
"rejects enabling an unconfigured %s channel without creating configuration",
async (channel) => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
const response = await put(channel, { enabled: true });
expect(response.status).toBe(422);
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
},
);
});
+49 -1
View File
@@ -1,6 +1,9 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { emailSettingsSchema } from "@/lib/validations/notifications";
import {
emailSettingsSchema,
notificationChannelEnabledSchema,
} from "@/lib/validations/notifications";
import { encrypt, decrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -53,6 +56,51 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
const enabledOnly = notificationChannelEnabledSchema.safeParse(body);
if (enabledOnly.success) {
const { enabled } = enabledOnly.data;
if (enabled) {
const existing = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "EMAIL" } },
});
let hasRecipient = false;
if (existing) {
try {
const config = JSON.parse(decrypt(existing.config)) as {
recipient?: string;
};
hasRecipient = !!config.recipient;
} catch {
hasRecipient = false;
}
}
if (!hasRecipient) {
return apiError(
"A recipient address is required when email is enabled",
422,
);
}
}
const result = await prisma.notificationChannel.updateMany({
where: { userId: user.id, type: "EMAIL" },
data: { enabled },
});
if (enabled && result.count === 0) {
return apiError(
"A recipient address is required when email is enabled",
422,
);
}
annotate({ action: { name: "settings.email.update" }, meta: { enabled } });
return apiSuccess({ saved: true });
}
const parsed = emailSettingsSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
+50 -1
View File
@@ -1,6 +1,9 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { ntfySettingsSchema } from "@/lib/validations/notifications";
import {
notificationChannelEnabledSchema,
ntfySettingsSchema,
} from "@/lib/validations/notifications";
import { encrypt, decrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -52,6 +55,52 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
const enabledOnly = notificationChannelEnabledSchema.safeParse(body);
if (enabledOnly.success) {
const { enabled } = enabledOnly.data;
if (enabled) {
const existing = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "NTFY" } },
});
let isConfigured = false;
if (existing) {
try {
const config = JSON.parse(decrypt(existing.config)) as {
serverUrl?: string;
topic?: string;
};
isConfigured = !!config.serverUrl && !!config.topic;
} catch {
isConfigured = false;
}
}
if (!isConfigured) {
return apiError(
"Server URL and topic are required when ntfy is enabled",
422,
);
}
}
const result = await prisma.notificationChannel.updateMany({
where: { userId: user.id, type: "NTFY" },
data: { enabled },
});
if (enabled && result.count === 0) {
return apiError(
"Server URL and topic are required when ntfy is enabled",
422,
);
}
annotate({ action: { name: "settings.ntfy.update" }, meta: { enabled } });
return apiSuccess({ saved: true });
}
const parsed = ntfySettingsSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
+100 -53
View File
@@ -3,6 +3,7 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { decrypt, encrypt } from "@/lib/crypto";
import { deleteTelegramWebhook, setTelegramWebhook } from "@/lib/telegram";
import { telegramSettingsSchema } from "@/lib/validations/telegram";
import { notificationChannelEnabledSchema } from "@/lib/validations/notifications";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -33,6 +34,64 @@ export const GET = apiHandler(async () => {
});
});
async function registerTelegramWebhook(botToken: string) {
const appUrl = process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL;
if (!appUrl) {
return apiError(
"Server configuration error: APP_URL (or NEXT_PUBLIC_APP_URL) is missing.",
500,
);
}
let appBaseUrl: URL;
try {
appBaseUrl = new URL(appUrl);
} catch {
return apiError("Server configuration error: APP_URL is invalid.", 500);
}
if (appBaseUrl.protocol !== "https:") {
return apiError(
"Telegram webhook requires a public HTTPS URL (no http/localhost).",
422,
);
}
const localHosts: Record<string, true> = {
localhost: true,
"127.0.0.1": true,
"::1": true,
};
if (localHosts[appBaseUrl.hostname.toLowerCase()]) {
return apiError(
"Telegram webhook requires a publicly reachable domain (localhost is not allowed).",
422,
);
}
if (
appBaseUrl.port &&
!["80", "88", "443", "8443"].includes(appBaseUrl.port)
) {
return apiError(
"Telegram webhook only allows ports 80, 88, 443, or 8443.",
422,
);
}
const webhookUrl = new URL("/api/telegram/webhook", appBaseUrl).toString();
const webhookSecret = process.env.TELEGRAM_WEBHOOK_SECRET;
const ok = await setTelegramWebhook(botToken, webhookUrl, webhookSecret);
if (!ok) {
return apiError(
"Failed to set Telegram webhook. Check bot token and reachability.",
422,
);
}
return null;
}
/**
* Update Telegram notification settings.
*/
@@ -54,6 +113,45 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
const enabledOnly = notificationChannelEnabledSchema.safeParse(body);
if (enabledOnly.success) {
const { enabled } = enabledOnly.data;
if (enabled && (!current.telegramBotToken || !current.telegramChatId)) {
return apiError(
"Bot token and chat ID are required when Telegram is enabled",
422,
);
}
const currentTokenPlain = current.telegramBotToken
? decrypt(current.telegramBotToken)
: null;
if (enabled && currentTokenPlain) {
const webhookError = await registerTelegramWebhook(currentTokenPlain);
if (webhookError) return webhookError;
}
await prisma.user.update({
where: { id: user.id },
data: { telegramEnabled: enabled },
});
await prisma.notificationChannel.updateMany({
where: { userId: user.id, type: "TELEGRAM" },
data: { enabled },
});
if (!enabled && currentTokenPlain) {
await deleteTelegramWebhook(currentTokenPlain).catch(() => {});
}
annotate({
action: { name: "settings.telegram.update" },
meta: { enabled },
});
return apiSuccess({ updated: true });
}
const result = z.safeParse(telegramSettingsSchema, body);
if (!result.success) {
return apiError("Invalid input", 422);
@@ -81,59 +179,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const tokenForWebhook = trimmedToken || currentTokenPlain;
if (enabled && tokenForWebhook) {
const appUrl = process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL;
if (!appUrl) {
return apiError(
"Server configuration error: APP_URL (or NEXT_PUBLIC_APP_URL) is missing.",
500,
);
}
let appBaseUrl: URL;
try {
appBaseUrl = new URL(appUrl);
} catch {
return apiError("Server configuration error: APP_URL is invalid.", 500);
}
if (appBaseUrl.protocol !== "https:") {
return apiError(
"Telegram webhook requires a public HTTPS URL (no http/localhost).",
422,
);
}
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
if (localHosts.has(appBaseUrl.hostname.toLowerCase())) {
return apiError(
"Telegram webhook requires a publicly reachable domain (localhost is not allowed).",
422,
);
}
if (
appBaseUrl.port &&
!["80", "88", "443", "8443"].includes(appBaseUrl.port)
) {
return apiError(
"Telegram webhook only allows ports 80, 88, 443, or 8443.",
422,
);
}
const webhookUrl = new URL("/api/telegram/webhook", appBaseUrl).toString();
const webhookSecret = process.env.TELEGRAM_WEBHOOK_SECRET;
const ok = await setTelegramWebhook(
tokenForWebhook,
webhookUrl,
webhookSecret,
);
if (!ok) {
return apiError(
"Failed to set Telegram webhook. Check bot token and reachability.",
422,
);
}
const webhookError = await registerTelegramWebhook(tokenForWebhook);
if (webhookError) return webhookError;
}
const data: Record<string, unknown> = { telegramEnabled: enabled };
+52 -1
View File
@@ -1,6 +1,9 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { webhookSettingsSchema } from "@/lib/validations/notifications";
import {
notificationChannelEnabledSchema,
webhookSettingsSchema,
} from "@/lib/validations/notifications";
import { encrypt, decrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -56,6 +59,54 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
const enabledOnly = notificationChannelEnabledSchema.safeParse(body);
if (enabledOnly.success) {
const { enabled } = enabledOnly.data;
if (enabled) {
const existing = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
});
let hasUrl = false;
if (existing) {
try {
const config = JSON.parse(decrypt(existing.config)) as {
url?: string;
};
hasUrl = !!config.url;
} catch {
hasUrl = false;
}
}
if (!hasUrl) {
return apiError(
"Webhook URL is required when the webhook is enabled",
422,
);
}
}
const result = await prisma.notificationChannel.updateMany({
where: { userId: user.id, type: "WEBHOOK" },
data: { enabled },
});
if (enabled && result.count === 0) {
return apiError(
"Webhook URL is required when the webhook is enabled",
422,
);
}
annotate({
action: { name: "settings.webhook.update" },
meta: { enabled },
});
return apiSuccess({ saved: true });
}
const parsed = webhookSettingsSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);