feat(FN-4078): add ntfy access token support to notifications
Adds ntfy access token support to Fusion's notification system, wiring the token through the core settings schema, dashboard UI (SettingsModal), engine notifier, and notification pipeline, with corresponding tests across core, dashboard, and engine packages; also updates settings and storage documen Fusion-Task-Id: FN-4078
This commit is contained in:
@@ -993,7 +993,15 @@ export function testNotification(providerId: string, config?: Record<string, unk
|
||||
* Backward-compatible ntfy test helper.
|
||||
* Wraps testNotification() while preserving the legacy function signature.
|
||||
*/
|
||||
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string; ntfyBaseUrl?: string }, projectId?: string): Promise<{ success: boolean }> {
|
||||
export function testNtfyNotification(
|
||||
config?: {
|
||||
ntfyEnabled?: boolean;
|
||||
ntfyTopic?: string;
|
||||
ntfyBaseUrl?: string;
|
||||
ntfyAccessToken?: string;
|
||||
},
|
||||
projectId?: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return testNotification("ntfy", config as Record<string, unknown> | undefined, projectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -397,6 +397,7 @@ export function SettingsModal({
|
||||
worktreeInitCommand: "",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyAccessToken: undefined,
|
||||
webhookEnabled: false,
|
||||
webhookUrl: undefined,
|
||||
webhookFormat: "generic",
|
||||
@@ -1186,6 +1187,7 @@ export function SettingsModal({
|
||||
ntfyEnabled: form.ntfyEnabled,
|
||||
ntfyTopic: form.ntfyTopic,
|
||||
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
|
||||
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
|
||||
}
|
||||
: providerId === "ntfy-message"
|
||||
? { messageEventType: "message:agent-to-user" }
|
||||
@@ -1215,7 +1217,17 @@ export function SettingsModal({
|
||||
} finally {
|
||||
setTestNotificationLoading((prev) => ({ ...prev, [providerId]: false }));
|
||||
}
|
||||
}, [addToast, form.ntfyBaseUrl, form.ntfyEnabled, form.ntfyTopic, form.webhookEnabled, form.webhookFormat, form.webhookUrl, projectId]);
|
||||
}, [
|
||||
addToast,
|
||||
form.ntfyAccessToken,
|
||||
form.ntfyBaseUrl,
|
||||
form.ntfyEnabled,
|
||||
form.ntfyTopic,
|
||||
form.webhookEnabled,
|
||||
form.webhookFormat,
|
||||
form.webhookUrl,
|
||||
projectId,
|
||||
]);
|
||||
|
||||
const handleBackupNow = useCallback(async () => {
|
||||
setBackupLoading(true);
|
||||
@@ -4860,6 +4872,21 @@ export function SettingsModal({
|
||||
<small>
|
||||
Leave blank to keep the default server: https://ntfy.sh. Custom servers must use http:// or https://.
|
||||
</small>
|
||||
<label htmlFor="ntfyAccessToken">Access token (optional)</label>
|
||||
<input
|
||||
id="ntfyAccessToken"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="tk_..."
|
||||
value={form.ntfyAccessToken || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyAccessToken: value || undefined }));
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
Leave blank to publish without authentication. When set, Fusion sends an Authorization Bearer header with ntfy requests.
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +182,7 @@ const defaultSettings = {
|
||||
worktreeInitCommand: "",
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyAccessToken: undefined,
|
||||
webhookEnabled: false,
|
||||
webhookUrl: undefined,
|
||||
webhookFormat: undefined,
|
||||
@@ -2994,6 +2995,9 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByLabelText("Dashboard Hostname")).toBeInTheDocument();
|
||||
expect(screen.getByText("Notify on events")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Test notification/ })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("Advanced"));
|
||||
expect(screen.getByLabelText("Access token (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows fallback, dreams, and mailbox message events for both providers", async () => {
|
||||
@@ -3048,18 +3052,46 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
await userEvent.click(screen.getByText("Advanced"));
|
||||
await userEvent.type(screen.getByLabelText("Access token (optional)"), "secret-token");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Test notification/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||
"ntfy",
|
||||
expect.objectContaining({ ntfyEnabled: true, ntfyTopic: "test-topic" }),
|
||||
expect.objectContaining({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyAccessToken: "secret-token",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a saved ntfy access token via global null-as-delete semantics", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openNotificationsSection();
|
||||
await userEvent.click(screen.getByText("Advanced"));
|
||||
const tokenInput = screen.getByLabelText("Access token (optional)");
|
||||
await userEvent.clear(tokenInput);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ntfyAccessToken: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls testNotification with ntfy message-event config when message test button clicked", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
|
||||
@@ -1659,6 +1659,53 @@ describe("POST /settings/test-ntfy", () => {
|
||||
expect(url).toBe("https://ntfy.saved.example/my-topic");
|
||||
});
|
||||
|
||||
it("sends Authorization header from saved ntfy access token", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token");
|
||||
});
|
||||
|
||||
it("prefers request ntfy access token override and ignores blank overrides", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const overrideRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({ ntfyAccessToken: "override-token" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(overrideRes.status).toBe(200);
|
||||
let options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
|
||||
|
||||
fetchSpy.mockClear();
|
||||
const blankRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-ntfy",
|
||||
JSON.stringify({ ntfyAccessToken: " " }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(blankRes.status).toBe(200);
|
||||
options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token");
|
||||
});
|
||||
|
||||
it("returns 400 when ntfy is not enabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: false,
|
||||
@@ -1844,6 +1891,59 @@ describe("POST /settings/test-notification", () => {
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
|
||||
it("ntfy provider sends Authorization header from saved or override token", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyAccessToken: "saved-token",
|
||||
});
|
||||
|
||||
const savedRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(savedRes.status).toBe(200);
|
||||
let options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token");
|
||||
|
||||
fetchSpy.mockClear();
|
||||
const overrideRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", ntfyAccessToken: "override-token" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(overrideRes.status).toBe(200);
|
||||
options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(options.headers).toHaveProperty("Authorization", "Bearer override-token");
|
||||
});
|
||||
|
||||
it("ntfy provider omits Authorization header when no token is configured", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyAccessToken: " ",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", ntfyAccessToken: " " }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect((options.headers as Record<string, string>).Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ntfy provider returns 400 when ntfy not enabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ntfyEnabled: false });
|
||||
|
||||
|
||||
@@ -1850,16 +1850,31 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
const tokenOverride = req.body?.ntfyAccessToken;
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
|
||||
const url = `${ntfyBaseUrl}/${topic}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
const ntfyAccessToken = requestToken ?? storedToken;
|
||||
if (ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
headers,
|
||||
body: "Fusion test notification — your notifications are working!",
|
||||
});
|
||||
|
||||
@@ -1976,16 +1991,31 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim()
|
||||
? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings")
|
||||
: undefined;
|
||||
const tokenOverride = config.ntfyAccessToken ?? body.ntfyAccessToken;
|
||||
if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") {
|
||||
throw badRequest("ntfy access token must be a string");
|
||||
}
|
||||
const requestToken = typeof tokenOverride === "string" && tokenOverride.trim()
|
||||
? tokenOverride.trim()
|
||||
: undefined;
|
||||
const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim()
|
||||
? settings.ntfyAccessToken.trim()
|
||||
: undefined;
|
||||
const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
|
||||
const url = `${ntfyBaseUrl}/${topic}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
const ntfyAccessToken = requestToken ?? storedToken;
|
||||
if (ntfyAccessToken) {
|
||||
headers.Authorization = `Bearer ${ntfyAccessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
headers,
|
||||
body: "Fusion test notification — your notifications are working!",
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user