mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(notifications): add a Gotify payload format to the webhook
Gotify's POST /message types priority as an integer, so the generic body, which sends the word, was refused with a 400 before the token was ever the question. The webhook card now offers a payload format: generic, which stays byte for byte what it was, and gotify, which sends title, message, an integer priority and extras. Priorities follow the Gotify Android client's channel thresholds: 5 for routine events (audible), 8 for medication reminders (the lowest heads-up value), 10 for urgent events. The extras declare plain text and carry the event name, masked to reminder in discreet mode as the generic body does. The choice lives in the encrypted channel config, so no migration: an absent format is generic, only gotify is written, and a save that omits the field keeps the stored choice. GET returns the resolved value. The sender comment no longer suggests an Authorization Bearer header for Gotify, which takes X-Gotify-Key or a token query parameter. Refs #947
This commit is contained in:
1 parent
92606f6409
commit
fafb5cdc08
17 files changed
+556
-50
No files matched your search
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { WebhookChannelConfig } from "@/lib/notifications/types";
|
||||
|
||||
/**
|
||||
* The save-time schema evaluates the same policy the sender does: the public
|
||||
@@ -61,22 +62,23 @@ export const GET = apiHandler(async () => {
|
||||
url: "",
|
||||
headerName: "",
|
||||
hasHeaderValue: false,
|
||||
format: "generic",
|
||||
});
|
||||
}
|
||||
|
||||
const config = JSON.parse(decrypt(channel.config)) as {
|
||||
url: string;
|
||||
headerName?: string;
|
||||
headerValue?: string;
|
||||
};
|
||||
const config = JSON.parse(
|
||||
decrypt(channel.config),
|
||||
) as Partial<WebhookChannelConfig>;
|
||||
|
||||
annotate({ action: { name: "settings.webhook.get" } });
|
||||
|
||||
return apiSuccess({
|
||||
enabled: channel.enabled,
|
||||
url: config.url,
|
||||
url: config.url ?? "",
|
||||
headerName: config.headerName ?? "",
|
||||
hasHeaderValue: !!config.headerValue,
|
||||
// A config saved before the choice existed carries no format: generic.
|
||||
format: config.format === "gotify" ? "gotify" : "generic",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +168,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
return apiValidationError("Invalid data", issues, 422);
|
||||
}
|
||||
|
||||
const { url, headerName, headerValue, enabled } = parsed.data;
|
||||
const { url, headerName, headerValue, format, enabled } = parsed.data;
|
||||
|
||||
if (enabled && !url) {
|
||||
return apiError("Webhook URL is required when the webhook is enabled", 422);
|
||||
@@ -174,24 +176,30 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// Preserve an existing header value when the client sends an empty one (the
|
||||
// GET path never returns the secret, so a save round-trip would otherwise
|
||||
// wipe it). A non-empty value replaces it.
|
||||
// wipe it). A non-empty value replaces it. An omitted format keeps the
|
||||
// stored one too, so a client that does not know the field cannot flip a
|
||||
// Gotify channel back to the generic body by saving.
|
||||
let nextHeaderValue = headerValue || undefined;
|
||||
if (!nextHeaderValue) {
|
||||
let nextFormat = format;
|
||||
if (!nextHeaderValue || nextFormat === undefined) {
|
||||
const existing = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
|
||||
});
|
||||
if (existing) {
|
||||
const prev = JSON.parse(decrypt(existing.config)) as {
|
||||
headerValue?: string;
|
||||
};
|
||||
nextHeaderValue = prev.headerValue || undefined;
|
||||
const prev = JSON.parse(
|
||||
decrypt(existing.config),
|
||||
) as Partial<WebhookChannelConfig>;
|
||||
if (!nextHeaderValue) nextHeaderValue = prev.headerValue || undefined;
|
||||
if (nextFormat === undefined) nextFormat = prev.format;
|
||||
}
|
||||
}
|
||||
|
||||
// Absent means generic, so only the Gotify choice is written.
|
||||
const config = JSON.stringify({
|
||||
url: url || "",
|
||||
...(headerName ? { headerName } : {}),
|
||||
...(nextHeaderValue ? { headerValue: nextHeaderValue } : {}),
|
||||
...(nextFormat === "gotify" ? { format: "gotify" } : {}),
|
||||
});
|
||||
|
||||
const encryptedConfig = encrypt(config);
|
||||
@@ -207,7 +215,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
update: { enabled, config: encryptedConfig },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "settings.webhook.update" }, meta: { enabled } });
|
||||
annotate({
|
||||
action: { name: "settings.webhook.update" },
|
||||
meta: { enabled, format: nextFormat === "gotify" ? "gotify" : "generic" },
|
||||
});
|
||||
|
||||
return apiSuccess({ saved: true });
|
||||
});
|
||||
Reference in new issue
Block a user