fix(settings): refuse enabling a channel the operator switched off

With the switches now enforced at dispatch, a user could still flip a
channel on, see the toggle stick, and get nothing. Refuse the enable
instead, with 403 and a message that names the reason: the ntfy and
Telegram config endpoints on both accepted body shapes, and the Web Push
subscribe endpoint, which is the enable step for that channel because it
creates the channel row.

Telegram is checked before the webhook registration, so a refused enable
makes no outbound call and stores nothing. Disabling a channel keeps
working while the switch is off — a user must always be able to turn a
channel off, whatever the operator has done.

The three refusals are published in the contract; `/api/settings/global-services`
already exposes the switches so a client can hide the control instead of
provoking the refusal.
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 02:02:15 +02:00
1 parent 8d2753f942
commit de0876b215
8 files changed
+368 -11

No files matched your search

@@ -26,12 +26,19 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/db", () => ({
prisma: {
// The route reads the operator's instance-wide Web Push switch before it
// touches the body; the default stub leaves it on so these cases keep
// asserting the validation refusals they were written for.
appSettings: { findUnique: vi.fn() },
pushSubscription: { upsert: vi.fn(), deleteMany: vi.fn() },
notificationChannel: { findFirst: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/crypto", () => ({ encrypt: (v: string) => `enc(${v})` }));
@@ -67,6 +74,12 @@ type Handler = (r: Request) => Promise<Response>;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
} as never);
});
describe("POST /api/notifications/web-push — the subscribe refusal", () => {
@@ -1,6 +1,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { isChannelGloballyEnabled } from "@/lib/app-settings";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { encrypt } from "@/lib/crypto";
@@ -43,6 +44,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "notifications.web-push.subscribe" } });
// Subscribing IS the enable step for this channel — it creates the
// WEB_PUSH `NotificationChannel` row. An operator who switched Web Push
// off instance-wide (`AppSettings.webPushGlobal`) gets a refusal that says
// so rather than a subscription that stores fine and delivers nothing.
if (!(await isChannelGloballyEnabled("WEB_PUSH"))) {
annotate({ meta: { refused: "globally_disabled" } });
return apiError(
"Web Push is disabled on this instance by the operator",
403,
);
}
let body: unknown;
try {
const raw = await request.text();