test(integration): move notification channel and workout route tests onto Postgres

The channel settings routes, their test-message routes, the reminder
thresholds and the workout detail, delete and batch-scope tests run through
the real handlers against the database. Only the far ends are faked (the
relay, the ntfy server, the Telegram Bot API, SMTP), and the tests assert what
those received. The private-origin source guard stays a unit test and now
points at the two files that carry its runtime proof.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 05:32:22 +02:00
1 parent ff9ef0883b
commit 43b86e46da
16 files changed
+2868 -3247

No files matched your search

@@ -1,418 +0,0 @@
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();
},
);
});
/**
* v1.32.1 — regression for iOS coordination issue #63: `PUT
* /api/settings/ntfy` reconstructed the entire stored config from the
* request body. `GET` never returns the write-only `authToken` (only
* `hasAuthToken: true/false`), so both native and web clients correctly
* OMIT — or round-trip an empty string through — the field on any
* unrelated edit. Rebuilding `config` without a preserve fallback
* silently dropped a working token on the next save.
*
* The fix mirrors the pre-existing `headerValue` preserve-on-empty
* contract in `src/app/api/settings/webhook/route.ts` (asserted here too,
* as a parity pin — that route was never broken, but any future drift
* between the two should fail the same suite). Covers exactly the four
* cases the report asks for.
*/
describe("PUT /api/settings/ntfy — write-only authToken preserve-on-omit contract", () => {
function upsertConfig() {
const call = vi.mocked(prisma.notificationChannel.upsert).mock
.calls[0]?.[0] as {
create: { config: string };
update: { config: string };
};
// The route always upserts — assert both arms agree, then decode one.
expect(call.update.config).toBe(call.create.config);
return JSON.parse(call.update.config.replace(/^encrypted:/, "")) as {
serverUrl: string;
topic: string;
authToken?: string;
};
}
it("an omitted authToken preserves the stored secret", 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", {
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
enabled: true,
});
expect(response.status).toBe(200);
expect(upsertConfig().authToken).toBe("secret");
});
it("an explicit empty-string authToken preserves the stored secret (matches current clients)", 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", {
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
authToken: "",
enabled: true,
});
expect(response.status).toBe(200);
expect(upsertConfig().authToken).toBe("secret");
});
it("a non-empty authToken replaces the stored secret", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"old-secret"}',
} as never);
const response = await put("ntfy", {
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
authToken: "new-secret",
enabled: true,
});
expect(response.status).toBe(200);
expect(upsertConfig().authToken).toBe("new-secret");
});
it("creating without a token remains valid where anonymous ntfy is allowed", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
const response = await put("ntfy", {
serverUrl: "https://ntfy.sh",
topic: "public-topic",
enabled: false,
});
expect(response.status).toBe(200);
const config = upsertConfig();
expect(config.authToken).toBeUndefined();
expect(config.topic).toBe("public-topic");
});
it("parity pin: the webhook headerValue preserve-on-empty contract this fix mirrors is intact", 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", {
url: "https://saved.example/hook",
headerName: "Authorization",
enabled: true,
});
expect(response.status).toBe(200);
const call = vi.mocked(prisma.notificationChannel.upsert).mock
.calls[0]?.[0] as { update: { config: string } };
const config = JSON.parse(
call.update.config.replace(/^encrypted:/, ""),
) as { headerValue?: string };
expect(config.headerValue).toBe("secret");
});
});
@@ -1,226 +0,0 @@
/**
* Enabling a channel the operator switched off instance-wide is refused.
*
* The three switches (`AppSettings.telegramGlobal` / `.ntfyGlobal` /
* `.webPushGlobal`) now stop delivery in the dispatcher. Without a matching
* refusal on the way in, a user would still see the toggle flip, the row
* would still say `enabled = true`, and nothing would ever arrive. These
* tests pin the refusal and the absence of any write behind it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
notificationChannel: {
findUnique: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
pushSubscription: { upsert: 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:/, "")),
}));
const setTelegramWebhookMock = vi.fn().mockResolvedValue(true);
vi.mock("@/lib/telegram", () => ({
setTelegramWebhook: (...args: unknown[]) => setTelegramWebhookMock(...args),
deleteTelegramWebhook: vi.fn().mockResolvedValue(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 putNtfy } from "../ntfy/route";
import { PUT as putTelegram } from "../telegram/route";
import { POST as postWebPush } from "../../notifications/web-push/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
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 WEB_PUSH_SUBSCRIPTION = {
endpoint: "https://push.example.com/subscription/abc",
keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) },
};
function stubSettings(over: Record<string, boolean>) {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
...over,
} as never);
}
function request(path: string, method: string, body: unknown) {
return new Request(`http://localhost${path}`, {
method,
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.notificationChannel.findUnique).mockResolvedValue({
config: 'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic"}',
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: "encrypted:saved-token",
telegramChatId: "saved-chat",
telegramEnabled: false,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(null);
});
describe("ntfy enable against the instance-wide switch", () => {
it("refuses with 403 and writes nothing when the operator switched ntfy off", async () => {
stubSettings({ ntfyGlobal: false });
const response = await putNtfy(
request("/api/settings/ntfy", "PUT", { enabled: true }) as never,
);
expect(response.status).toBe(403);
await expect(response.json()).resolves.toMatchObject({
data: null,
error: expect.stringContaining("disabled on this instance"),
});
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("still allows disabling the channel while the switch is off", async () => {
stubSettings({ ntfyGlobal: false });
const response = await putNtfy(
request("/api/settings/ntfy", "PUT", { enabled: false }) as never,
);
expect(response.status).toBe(200);
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "NTFY" },
data: { enabled: false },
});
});
it("allows enabling while the switch is on", async () => {
stubSettings({ ntfyGlobal: true });
const response = await putNtfy(
request("/api/settings/ntfy", "PUT", { enabled: true }) as never,
);
expect(response.status).toBe(200);
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "NTFY" },
data: { enabled: true },
});
});
});
describe("Telegram enable against the instance-wide switch", () => {
it("refuses with 403, registers no webhook, and writes nothing", async () => {
stubSettings({ telegramGlobal: false });
const response = await putTelegram(
request("/api/settings/telegram", "PUT", { enabled: true }) as never,
);
expect(response.status).toBe(403);
expect(setTelegramWebhookMock).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
});
it("refuses a full-config save that enables the channel", async () => {
stubSettings({ telegramGlobal: false });
const response = await putTelegram(
request("/api/settings/telegram", "PUT", {
botToken: "123456:AAaaBBbbCCccDDddEEeeFFffGGgg-HHhhIIii",
chatId: "9876543",
enabled: true,
}) as never,
);
expect(response.status).toBe(403);
expect(setTelegramWebhookMock).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("Web Push subscribe against the instance-wide switch", () => {
it("refuses with 403 and stores no subscription", async () => {
stubSettings({ webPushGlobal: false });
const response = await postWebPush(
request(
"/api/notifications/web-push",
"POST",
WEB_PUSH_SUBSCRIPTION,
) as never,
);
expect(response.status).toBe(403);
expect(prisma.pushSubscription.upsert).not.toHaveBeenCalled();
expect(prisma.notificationChannel.create).not.toHaveBeenCalled();
});
it("stores the subscription while the switch is on", async () => {
stubSettings({ webPushGlobal: true });
const response = await postWebPush(
request(
"/api/notifications/web-push",
"POST",
WEB_PUSH_SUBSCRIPTION,
) as never,
);
expect(response.status).toBe(200);
expect(prisma.pushSubscription.upsert).toHaveBeenCalledTimes(1);
});
});
@@ -1,138 +0,0 @@
/**
* POST /api/settings/email/test — a failed send names its cause.
*
* Before this the route answered every failure with a bare 500, so the card
* read "Test failed" whether the mail server refused the login, timed out,
* or rejected the recipient.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { notificationChannel: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
const sendViaEmailMock = vi.fn();
vi.mock("@/lib/notifications/senders/email", () => ({
sendViaEmail: (...args: unknown[]) => sendViaEmailMock(...args),
}));
vi.mock("@/lib/notifications/senders/email-config", () => ({
isEmailConfigured: () => true,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
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: 4,
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 { POST } from "../email/test/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
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 },
};
function post(): Promise<Response> {
return (POST as (request: Request) => Promise<Response>)(
new Request("http://localhost/api/settings/email/test", {
method: "POST",
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: 'encrypted:{"recipient":"you@example.com"}',
} as never);
});
describe("POST /api/settings/email/test", () => {
it.each([
["credentials_rejected", 535, "rejected the credentials (SMTP 535)"],
["upstream_rejected", 550, "refused the message (SMTP 550)"],
])(
"answers an SMTP rejection with 502, %s and the reply code",
async (failureCode, smtpCode, sentence) => {
sendViaEmailMock.mockResolvedValue({
ok: false,
hardReject: smtpCode >= 500,
reason: "email_smtp_5xx",
failureCode,
smtpCode,
});
const response = await post();
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: failureCode,
smtpCode,
});
expect(json.error).toContain(sentence);
},
);
it("answers a timeout with 502 and the timeout code", async () => {
sendViaEmailMock.mockResolvedValue({
ok: false,
hardReject: false,
reason: "email_smtp_error",
failureCode: "timeout",
});
const response = await post();
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
meta: { errorCode: "timeout" },
});
});
it("keeps a 500 for a failure the sender could not name", async () => {
sendViaEmailMock.mockResolvedValue({
ok: false,
hardReject: false,
reason: "email_smtp_error",
});
const response = await post();
expect(response.status).toBe(500);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.internal",
});
});
it("answers a delivered test with 200", async () => {
sendViaEmailMock.mockResolvedValue({ ok: true });
const response = await post();
expect(response.status).toBe(200);
});
});
@@ -1,510 +1,17 @@
/**
* #947 — a webhook or ntfy target on a private network, at the four routes
* that meet it.
* #947: the test button and the dispatcher must reach the SAME sender
* function for a webhook or ntfy target, so the private-origin decision
* cannot differ between the button that says "works" and the reminder that
* then does not arrive. A structural check over the sources.
*
* The save routes evaluate the same policy the senders do, so a private
* target that is not listed in NOTIFICATION_PRIVATE_ORIGINS is a 422 that
* names the reason (`meta.errorCode = private_origin_not_approved`) instead
* of a generic "Invalid data", and a listed literal address saves although
* the plain public floor would refuse it. The test routes forward the
* sender's own refusal as a 422 with the same code instead of the bare 500
* that made the reporter read the wide-event log to learn it was policy, and
* answer a relay's error status with a 502 that carries the status and what
* the relay said.
*
* The last block is the structural half: the test button and the dispatcher
* must reach the SAME sender function, so the decision cannot differ between
* the button that says "works" and the reminder that then does not arrive.
* The behaviour at the routes (save-time refusals, the test routes' 422 and
* 502 answers) is proven against the real database in
* `tests/integration/routes/settings-channels.test.ts` and
* `tests/integration/routes/settings-channel-tests.test.ts`.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findUnique: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
vi.mock("@/lib/app-settings", () => ({
isChannelGloballyEnabled: vi.fn().mockResolvedValue(true),
}));
const sendViaWebhookMock = vi.fn();
const sendViaNtfyMock = vi.fn();
vi.mock("@/lib/notifications/senders/webhook", () => ({
sendViaWebhook: (...args: unknown[]) => sendViaWebhookMock(...args),
}));
vi.mock("@/lib/notifications/senders/ntfy", () => ({
sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
const emitIfSampledMock = vi.fn();
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: (...args: unknown[]) => emitIfSampledMock(...args),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
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 { POST as testWebhook } from "../webhook/test/route";
import { POST as testNtfy } from "../ntfy/test/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
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 },
};
type Route = (request: Request) => Promise<Response>;
function put(path: string, body: unknown): Promise<Response> {
const route = (path === "webhook" ? putWebhook : putNtfy) as Route;
return route(
new Request(`http://localhost/api/settings/${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
function post(path: string): Promise<Response> {
const route = (path === "webhook" ? testWebhook : testNtfy) as Route;
return route(
new Request(`http://localhost/api/settings/${path}/test`, {
method: "POST",
}),
);
}
const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS;
beforeEach(() => {
vi.clearAllMocks();
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
});
afterEach(() => {
if (ORIGINAL_PRIVATE_ORIGINS === undefined) {
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
} else {
process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS;
}
});
describe("PUT /api/settings/webhook — private targets", () => {
it("refuses an unlisted private address with the reason, and stores nothing", async () => {
const response = await put("webhook", {
url: "http://10.0.0.5:8080/message",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.data).toBeNull();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("refuses a sub-host of a listed origin: a grant names one origin only", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
const response = await put("webhook", {
url: "https://push.gotify.example.com/message",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("saves a listed private address the public floor alone would refuse", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080";
const response = await put("webhook", {
url: "http://10.0.0.5:8080/message",
enabled: true,
});
expect(response.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0];
expect(call.create.config).toBe(
'encrypted:{"url":"http://10.0.0.5:8080/message"}',
);
});
it("keeps the plain shape refusal for a body that is not a private-origin case", async () => {
const response = await put("webhook", { url: 42, enabled: true });
expect(response.status).toBe(422);
const json = await response.json();
expect(json.error).toBe("Invalid data");
// No private-origin code: only the status's generic one.
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.invalid",
});
});
it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => {
// Host networking: an exact loopback origin with its port is a grant like
// any other. Unlisted, it is refused with the code that says "list it",
// because listing it does open it.
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080";
const refused = await put("webhook", {
url: "http://127.0.0.1:8080/message",
enabled: true,
});
expect(refused.status).toBe(422);
await expect(refused.json()).resolves.toMatchObject({
meta: { errorCode: "private_origin_not_approved" },
});
const saved = await put("webhook", {
url: "http://localhost:8080/message",
enabled: true,
});
expect(saved.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
it("answers a metadata or unspecified target with the not-grantable code, listed or not (M1)", async () => {
// This used to pin a dead end: the route said "list it", the parser
// dropped the listing, and the next save said "list it" again. A target
// no grant can open gets its own code and a message that says so.
vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.NOTIFICATION_PRIVATE_ORIGINS =
"http://169.254.169.254, http://0.0.0.0:8080";
for (const url of [
"http://169.254.169.254/latest",
"http://0.0.0.0:8080/message",
"http://[fe80::1]:8080/message",
]) {
const response = await put("webhook", { url, enabled: true });
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_grantable",
});
expect(json.error).toMatch(/no operator grant can open/);
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(json.details.issues.length).toBeGreaterThan(0);
}
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("saves an mDNS name the input floor refuses, once the operator lists it", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://gotify.local";
const refused = await put("webhook", {
url: "http://ntfy.local/message",
enabled: true,
});
expect(refused.status).toBe(422);
await expect(refused.json()).resolves.toMatchObject({
meta: { errorCode: "private_origin_not_approved" },
});
const saved = await put("webhook", {
url: "http://gotify.local/message",
enabled: true,
});
expect(saved.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
});
describe("PUT /api/settings/ntfy — private servers", () => {
it("refuses an unlisted private server with the reason, and stores nothing", async () => {
const response = await put("ntfy", {
serverUrl: "http://192.168.1.20:8080",
topic: "health",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("saves a listed private server", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080";
const response = await put("ntfy", {
serverUrl: "http://192.168.1.20:8080",
topic: "health",
enabled: true,
});
expect(response.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
it("refuses a sibling port of a listed server", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080";
const response = await put("ntfy", {
serverUrl: "http://192.168.1.20:9090",
topic: "health",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
});
});
describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", () => {
beforeEach(() => {
vi.mocked(prisma.notificationChannel.findUnique).mockImplementation(
(async (args: { where: { userId_type: { type: string } } }) =>
args.where.userId_type.type === "WEBHOOK"
? { config: 'encrypted:{"url":"https://gotify.lan/message"}' }
: {
config:
'encrypted:{"serverUrl":"https://ntfy.lan","topic":"health"}',
}) as never,
);
});
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: answers a private-origin refusal with 422 and the code, not a 500",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_private_origin_refused`,
errorCode: "private_origin_not_approved",
});
const response = await post(path);
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: forwards the not-grantable code with a message that does not say list it (M1)",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_private_origin_refused`,
errorCode: "private_origin_not_grantable",
});
const response = await post(path);
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_grantable",
});
expect(json.error).toMatch(/no operator grant can open/);
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: a relay that answered 400 is a 502 naming the status and what it said, not a bare 500 (#947: the card could only say Test failed)",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_400`,
statusCode: 400,
upstreamBody: '{"error":"Bad Request","errorCode":400}',
});
const response = await post(path);
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "upstream_rejected",
upstreamStatus: 400,
upstreamBody: '{"error":"Bad Request","errorCode":400}',
});
expect(json.error).toContain("HTTP 400");
// A user's relay refusing is not an operator error: the event is warn.
expect(emitIfSampledMock).toHaveBeenCalledWith(
expect.objectContaining({ level: "warn" }),
);
},
);
it.each([
[401, "credentials_rejected"],
[403, "credentials_rejected"],
[404, "endpoint_not_found"],
[410, "endpoint_not_found"],
[415, "upstream_rejected"],
[422, "upstream_rejected"],
[429, "rate_limited"],
[503, "upstream_error"],
[307, "redirected"],
])(
"webhook: an upstream %i maps to %s, without a body when the sender dropped it",
async (status, code) => {
sendViaWebhookMock.mockResolvedValue({
ok: false,
hardReject: false,
reason: `webhook_${status}`,
statusCode: status,
});
const response = await post("webhook");
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: code,
upstreamStatus: status,
});
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: a timeout without a status is a 502 with the timeout code",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_network_error`,
failureCode: "timeout",
});
const response = await post(path);
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
meta: { errorCode: "timeout" },
});
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: only a failure the sender could not name stays a 500",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_network_error`,
});
const response = await post(path);
expect(response.status).toBe(500);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.internal",
});
expect(emitIfSampledMock).toHaveBeenCalledWith(
expect.objectContaining({ level: "error" }),
);
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])("%s: a delivered test is a 200", async (path, sender) => {
sender.mockResolvedValue({ ok: true, statusCode: 200 });
const response = await post(path);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { sent: true },
error: null,
});
});
});
import { describe, expect, it } from "vitest";
describe("the test button and the dispatcher share one sender", () => {
const SRC = join(process.cwd(), "src");
@@ -1,202 +0,0 @@
/**
* v1.4.27 — settings/telegram/test locale path.
*
* Asserts the per-user Telegram test message renders in the user's
* persisted `User.locale` rather than the hardcoded English string
* the pre-fix route used. The actual translator behaviour is covered
* by `notifications/__tests__/admin-locale.test.ts`; this file is
* the route-level smoke test that confirms the wiring.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ ok: true, data }),
apiError: (message: string, status: number) => ({
ok: false,
error: message,
status,
}),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (s: string) => `decrypted(${s})`,
}));
const sendTelegramMessageMock = vi.fn();
vi.mock("@/lib/telegram", () => ({
sendTelegramMessage: (...args: unknown[]) => sendTelegramMessageMock(...args),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
const tCalls: Array<{ locale: string; key: string }> = [];
vi.mock("@/lib/i18n/server-translator", () => ({
getServerTranslator: (locale: string) => ({
locale,
t: (key: string) => {
tCalls.push({ locale, key });
const fakeStrings: Record<string, Record<string, string>> = {
de: {
"notifications.user.telegramTestBody":
"HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.",
},
en: {
"notifications.user.telegramTestBody":
"HealthLog: connection successful. Telegram notifications are active.",
},
fr: {
"notifications.user.telegramTestBody":
"HealthLog: connexion réussie. Les notifications Telegram sont actives.",
},
};
return (fakeStrings[locale] ?? fakeStrings.en)[key] ?? key;
},
}),
}));
import { POST } from "../telegram/test/route";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
beforeEach(() => {
vi.resetAllMocks();
tCalls.length = 0;
vi.mocked(requireAuth).mockResolvedValue({
user: { id: "user-1" },
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
sendTelegramMessageMock.mockResolvedValue({ ok: true });
});
afterEach(() => {
vi.clearAllMocks();
});
describe("POST /api/settings/telegram/test — locale-aware message", () => {
it("sends the German body when User.locale=de", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "de",
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1);
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.",
);
expect(tCalls).toEqual([
{
locale: "de",
key: "notifications.user.telegramTestBody",
},
]);
expect((result as { ok: boolean }).ok).toBe(true);
});
it("sends the French body when User.locale=fr", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "fr",
} as never);
await (POST as () => Promise<unknown>)();
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: connexion réussie. Les notifications Telegram sont actives.",
);
});
it("falls back to the default locale (en) when User.locale is null", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: null,
} as never);
await (POST as () => Promise<unknown>)();
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: connection successful. Telegram notifications are active.",
);
expect(tCalls[0].locale).toBe("en");
});
it("falls back to the default locale (en) when User.locale is unsupported", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "klingon",
} as never);
await (POST as () => Promise<unknown>)();
expect(tCalls[0].locale).toBe("en");
});
it("returns a 422 error when bot token is not configured", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: null,
telegramChatId: null,
locale: "de",
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).not.toHaveBeenCalled();
expect((result as { status: number }).status).toBe(422);
});
it("returns a 429 error when rate-limited", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).not.toHaveBeenCalled();
expect((result as { status: number }).status).toBe(429);
});
it("returns a 422 error when the Telegram send fails", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "en",
} as never);
sendTelegramMessageMock.mockResolvedValueOnce({ ok: false });
const result = await (POST as () => Promise<unknown>)();
expect((result as { status: number }).status).toBe(422);
});
});
@@ -1,194 +0,0 @@
/**
* The webhook payload format at the settings route (#947).
*
* Absent means generic, so a config saved before the choice existed keeps
* sending the body it always sent. Only the Gotify choice is written, an
* omitted format keeps the stored one, and the GET reports the resolved
* value rather than leaving the client to guess the default.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findUnique: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
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: 4,
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 { GET, PUT } from "../webhook/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
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 },
};
type Route = (request: Request) => Promise<Response>;
function put(body: unknown): Promise<Response> {
return (PUT as Route)(
new Request("http://localhost/api/settings/webhook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
function get(): Promise<Response> {
return (GET as Route)(new Request("http://localhost/api/settings/webhook"));
}
function stored(): string {
const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0];
return call.create.config as string;
}
function existing(config: Record<string, unknown>) {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
enabled: true,
config: `encrypted:${JSON.stringify(config)}`,
} as never);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
});
describe("PUT /api/settings/webhook — payload format", () => {
it("stores the Gotify choice", async () => {
const response = await put({
url: "https://gotify.example.com/message",
headerName: "X-Gotify-Key",
headerValue: "AbCdEfGh123",
format: "gotify",
enabled: true,
});
expect(response.status).toBe(200);
expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toEqual({
url: "https://gotify.example.com/message",
headerName: "X-Gotify-Key",
headerValue: "AbCdEfGh123",
format: "gotify",
});
});
it("writes nothing for generic, so the stored config keeps its old shape", async () => {
await put({
url: "https://relay.example.com/hook",
format: "generic",
enabled: true,
});
expect(stored()).toBe('encrypted:{"url":"https://relay.example.com/hook"}');
});
it("keeps a stored Gotify choice when the body omits the field", async () => {
existing({
url: "https://gotify.example.com/message",
headerValue: "AbCdEfGh123",
format: "gotify",
});
await put({
url: "https://gotify.example.com/message",
headerValue: "NewToken456",
enabled: true,
});
expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toMatchObject({
headerValue: "NewToken456",
format: "gotify",
});
});
it("switches a Gotify channel back to generic when asked", async () => {
existing({ url: "https://gotify.example.com/message", format: "gotify" });
await put({
url: "https://gotify.example.com/message",
format: "generic",
enabled: true,
});
expect(stored()).toBe(
'encrypted:{"url":"https://gotify.example.com/message"}',
);
});
it("refuses a format it does not know", async () => {
const response = await put({
url: "https://relay.example.com/hook",
format: "discord",
enabled: true,
});
expect(response.status).toBe(422);
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
});
describe("GET /api/settings/webhook — payload format", () => {
it("reports generic for a config saved before the choice existed", async () => {
existing({ url: "https://relay.example.com/hook" });
const json = await (await get()).json();
expect(json.data.format).toBe("generic");
});
it("reports the stored Gotify choice", async () => {
existing({ url: "https://gotify.example.com/message", format: "gotify" });
const json = await (await get()).json();
expect(json.data.format).toBe("gotify");
});
it("reports generic when no channel exists", async () => {
const json = await (await get()).json();
expect(json.data).toEqual({
enabled: false,
url: "",
headerName: "",
hasHeaderValue: false,
format: "generic",
});
});
});
@@ -1,119 +0,0 @@
/**
* v1.16.11 — reminder-thresholds GET: the operator-level late / missed
* minutes ride along unchanged; the new `lowStockRunwayDays` field is
* PER-USER (from `notificationPrefs.medication`), so the endpoint
* reflects whatever the notification-prefs PATCH persisted — the read
* half of the settings round-trip.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
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 },
};
function mkGet(): Request {
return new Request("http://localhost/api/settings/reminder-thresholds");
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
} as never);
});
describe("GET /api/settings/reminder-thresholds", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(401);
});
it("returns the default low-stock threshold (7) for a fresh user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: {
lateMinutes: number;
missedMinutes: number;
lowStockRunwayDays: number | null;
reorderLeadDays: number;
};
};
expect(env.data).toEqual({
lateMinutes: 120,
missedMinutes: 240,
lowStockRunwayDays: 7,
// v1.17.0 — the per-user reorder lead default rides along.
reorderLeadDays: 10,
});
});
it("reflects the per-user threshold persisted by the prefs PATCH", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: 21 } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { lowStockRunwayDays: number | null };
};
expect(env.data.lowStockRunwayDays).toBe(21);
});
it("reflects the OFF state as null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: null } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { lowStockRunwayDays: number | null };
};
expect(env.data.lowStockRunwayDays).toBe(null);
});
});
@@ -1,150 +0,0 @@
/**
* The webhook test button against a Discord webhook URL, through the real
* sender with only the network mocked.
*
* The mocked target answers the way Discord does: 400 with code 50006
* ("Cannot send an empty message") for a body without `content` or `embeds`,
* 204 otherwise. The generic body used to carry neither, so the test button
* and every reminder failed against a Discord URL the settings card names.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
const safeFetchMock = vi.fn();
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
SafeFetchError: class SafeFetchError extends Error {
kind: string;
constructor(message: string, kind: string) {
super(message);
this.kind = kind;
}
},
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
recordPushAttemptForPayload: vi.fn(),
}));
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: 4,
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 { POST as postTest } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const DISCORD_URL =
"https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789";
/** Answers the way Discord's execute-webhook endpoint does. */
function discordAnswer(_url: string, init: { body: string }): Response {
const body = JSON.parse(init.body) as Record<string, unknown>;
const content = typeof body.content === "string" ? body.content : "";
if (content.trim().length === 0 && body.embeds === undefined) {
return new Response(
JSON.stringify({ message: "Cannot send an empty message", code: 50006 }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
if (content.length > 2000) {
return new Response(
JSON.stringify({ message: "Invalid Form Body", code: 50035 }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
return new Response(null, { status: 204 });
}
const POST = postTest as (request: Request) => Promise<Response>;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" },
} as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: `encrypted:${JSON.stringify({ url: DISCORD_URL })}`,
} as never);
safeFetchMock.mockImplementation(
async (url: string, init: { body: string }) => discordAnswer(url, init),
);
});
describe("POST /api/settings/webhook/test — a Discord webhook URL", () => {
it("delivers: the generic body carries the content Discord requires", async () => {
const response = await POST(
new Request("http://localhost/api/settings/webhook/test", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect((await response.json()).data).toEqual({ sent: true });
expect(safeFetchMock).toHaveBeenCalledTimes(1);
const [url, init] = safeFetchMock.mock.calls[0] as [
string,
{ body: string },
];
expect(url).toBe(DISCORD_URL);
const sent = JSON.parse(init.body);
expect(sent.content).toBe(
"HealthLog Test\nHealthLog: Connection successful! Webhook notifications are active.",
);
expect(sent.allowed_mentions).toEqual({ parse: [] });
});
it("passes Discord's own error through when the target still refuses", async () => {
safeFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ message: "Unknown Webhook", code: 10015 }),
{ status: 404, headers: { "Content-Type": "application/json" } },
),
);
const response = await POST(
new Request("http://localhost/api/settings/webhook/test", {
method: "POST",
}),
);
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta.upstreamStatus).toBe(404);
expect(json.meta.upstreamBody).toContain("Unknown Webhook");
});
});