fix(settings): stop ntfy PUT from clearing the stored auth token on unrelated edits

PUT /api/settings/ntfy reconstructed the entire stored config from the
request body. GET never returns the write-only authToken (only
hasAuthToken), so clients correctly omit it on any edit that doesn't
touch the credential — but the route treated an omitted or empty token
as "clear it", silently destroying a working notification credential on
routine edits like toggling enabled.

An omitted or empty authToken now preserves the currently stored value;
only a non-empty value replaces it. Mirrors the identical headerValue
preserve-on-empty contract already shipped on the webhook channel route.
This commit is contained in:
Marc-André Bombeck committed 2026-07-23 09:28:39 +02:00
1 parent 1371bb1cf9
commit d750c1827e
2 files changed
+140 -1

No files matched your search

@@ -298,3 +298,121 @@ describe("notification channel enable-only routes", () => {
},
);
});
/**
* 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");
});
});
+22 -1
View File
@@ -113,10 +113,31 @@ export const PUT = apiHandler(async (request: NextRequest) => {
);
}
// v1.32.1 — preserve an existing auth token when the client sends an
// empty one (issue #63). GET never returns the secret (`hasAuthToken`
// only), so both native and web clients correctly omit — or round-trip
// an empty string through — the field on any unrelated edit (e.g.
// toggling `enabled`). Reconstructing `config` from scratch without this
// fallback silently dropped the token on every such save. Mirrors the
// identical `headerValue` preserve-on-empty contract in
// `src/app/api/settings/webhook/route.ts`. A non-empty value replaces it.
let nextAuthToken = authToken || undefined;
if (!nextAuthToken) {
const existing = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "NTFY" } },
});
if (existing) {
const prev = JSON.parse(decrypt(existing.config)) as {
authToken?: string;
};
nextAuthToken = prev.authToken || undefined;
}
}
const config = JSON.stringify({
serverUrl: serverUrl || "https://ntfy.sh",
topic: topic || "",
...(authToken ? { authToken } : {}),
...(nextAuthToken ? { authToken: nextAuthToken } : {}),
});
const encryptedConfig = encrypt(config);