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:
@@ -37,7 +37,10 @@ function createStore(settings: Partial<Settings> = {}) {
|
||||
describe("message notification pipeline", () => {
|
||||
it("dispatches agent-originated messages and ignores user-to-agent", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
|
||||
const store = createStore({ ntfyEvents: ["message:agent-to-user", "message:agent-to-agent"] });
|
||||
const store = createStore({
|
||||
ntfyEvents: ["message:agent-to-user", "message:agent-to-agent"],
|
||||
ntfyAccessToken: "token-123",
|
||||
});
|
||||
const messageStore = new TestMessageStore();
|
||||
|
||||
const service = new NotificationService(store as any, {
|
||||
@@ -53,6 +56,7 @@ describe("message notification pipeline", () => {
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/test-topic");
|
||||
const firstHeaders = (fetchSpy.mock.calls[0]?.[1] as RequestInit).headers as Record<string, string>;
|
||||
expect(firstHeaders.Title).toContain("Triage Bot");
|
||||
expect(firstHeaders.Authorization).toBe("Bearer token-123");
|
||||
const firstBody = String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body);
|
||||
expect(firstBody).toContain("hi");
|
||||
|
||||
|
||||
@@ -136,7 +136,12 @@ describe("NotificationService", () => {
|
||||
await service.start();
|
||||
|
||||
expect(initSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ topic: "demo", projectId: "p1", ntfyBaseUrl: "https://n" }),
|
||||
expect.objectContaining({
|
||||
topic: "demo",
|
||||
projectId: "p1",
|
||||
ntfyBaseUrl: "https://n",
|
||||
ntfyAccessToken: undefined,
|
||||
}),
|
||||
);
|
||||
initSpy.mockRestore();
|
||||
});
|
||||
@@ -150,6 +155,48 @@ describe("NotificationService", () => {
|
||||
initSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("reconfigures the ntfy provider when the access token changes without logging the token", async () => {
|
||||
const store = createStore({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "demo",
|
||||
ntfyAccessToken: "old-token",
|
||||
});
|
||||
const initSpy = vi.spyOn(NtfyNotificationProvider.prototype, "initialize");
|
||||
|
||||
const service = new NotificationService(store as any, { projectId: "p1" });
|
||||
await service.start();
|
||||
|
||||
store.emit("settings:updated", {
|
||||
settings: {
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "demo",
|
||||
ntfyAccessToken: "new-token",
|
||||
} as Settings,
|
||||
previous: {
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "demo",
|
||||
ntfyAccessToken: "old-token",
|
||||
} as Settings,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(initSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
topic: "demo",
|
||||
projectId: "p1",
|
||||
ntfyAccessToken: "new-token",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(schedulerLog.log).toHaveBeenCalledWith("NotificationService ntfy access token updated");
|
||||
});
|
||||
expect(schedulerLog.log).not.toHaveBeenCalledWith(expect.stringContaining("new-token"));
|
||||
expect(schedulerLog.log).not.toHaveBeenCalledWith(expect.stringContaining("old-token"));
|
||||
initSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("dispatches message:agent-to-user from message:sent", async () => {
|
||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||
const messageStore = new EventEmitter();
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("NtfyNotificationProvider", () => {
|
||||
await provider.initialize({
|
||||
topic: "topic-a",
|
||||
ntfyBaseUrl: "https://ntfy.local",
|
||||
ntfyAccessToken: "secret-token",
|
||||
dashboardHost: "http://dash",
|
||||
projectId: "p1",
|
||||
});
|
||||
@@ -63,6 +64,7 @@ describe("NtfyNotificationProvider", () => {
|
||||
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
topic: "topic-a",
|
||||
ntfyAccessToken: "secret-token",
|
||||
title: expectedTitle,
|
||||
priority,
|
||||
message: expect.stringContaining(messagePart),
|
||||
|
||||
@@ -152,6 +152,7 @@ export class NotificationService {
|
||||
settings.ntfyEnabled !== previous.ntfyEnabled ||
|
||||
settings.ntfyTopic !== previous.ntfyTopic ||
|
||||
settings.ntfyBaseUrl !== previous.ntfyBaseUrl ||
|
||||
settings.ntfyAccessToken !== previous.ntfyAccessToken ||
|
||||
settings.ntfyDashboardHost !== previous.ntfyDashboardHost ||
|
||||
JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)
|
||||
) {
|
||||
@@ -168,6 +169,8 @@ export class NotificationService {
|
||||
schedulerLog.log("NotificationService ntfy topic updated");
|
||||
} else if (settings.ntfyBaseUrl !== previous.ntfyBaseUrl) {
|
||||
schedulerLog.log("NotificationService ntfy base URL updated");
|
||||
} else if (settings.ntfyAccessToken !== previous.ntfyAccessToken) {
|
||||
schedulerLog.log("NotificationService ntfy access token updated");
|
||||
} else if (settings.ntfyDashboardHost !== previous.ntfyDashboardHost) {
|
||||
schedulerLog.log("NotificationService ntfy dashboard host updated");
|
||||
} else if (JSON.stringify(settings.ntfyEvents) !== JSON.stringify(previous.ntfyEvents)) {
|
||||
@@ -206,6 +209,7 @@ export class NotificationService {
|
||||
await this.ntfyProvider.initialize?.({
|
||||
topic: settings.ntfyTopic,
|
||||
ntfyBaseUrl: settings.ntfyBaseUrl ?? this.options.ntfyBaseUrl,
|
||||
ntfyAccessToken: settings.ntfyAccessToken,
|
||||
dashboardHost: settings.ntfyDashboardHost,
|
||||
events: settings.ntfyEvents ?? [...DEFAULT_NTFY_EVENTS],
|
||||
projectId: this.options.projectId,
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface NtfyProviderConfig {
|
||||
dashboardHost?: string;
|
||||
/** Project identifier for deep links */
|
||||
projectId?: string;
|
||||
/** Optional access token used for authenticated publishes */
|
||||
ntfyAccessToken?: string;
|
||||
/** Events to enable (default: DEFAULT_NTFY_EVENTS) */
|
||||
events?: NtfyNotificationEvent[];
|
||||
}
|
||||
@@ -208,6 +210,7 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
||||
|
||||
const response = await sendNtfyNotificationWithResult({
|
||||
ntfyBaseUrl: this.config.ntfyBaseUrl,
|
||||
ntfyAccessToken: this.config.ntfyAccessToken,
|
||||
topic: this.config.topic,
|
||||
title: content.title,
|
||||
message: content.message,
|
||||
|
||||
@@ -37,10 +37,12 @@ export interface NtfyNotificationConfigInput {
|
||||
events?: NtfyNotificationEvent[];
|
||||
projectId?: string;
|
||||
ntfyBaseUrl?: string;
|
||||
ntfyAccessToken?: string;
|
||||
}
|
||||
|
||||
export interface SendNtfyNotificationInput {
|
||||
ntfyBaseUrl?: string;
|
||||
ntfyAccessToken?: string;
|
||||
topic: string;
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -140,6 +142,7 @@ export function buildNtfyClickUrl(options: {
|
||||
*/
|
||||
export async function sendNtfyNotificationWithResult({
|
||||
ntfyBaseUrl,
|
||||
ntfyAccessToken,
|
||||
topic,
|
||||
title,
|
||||
message,
|
||||
@@ -158,6 +161,11 @@ export async function sendNtfyNotificationWithResult({
|
||||
headers.Click = clickUrl;
|
||||
}
|
||||
|
||||
const trimmedToken = ntfyAccessToken?.trim();
|
||||
if (trimmedToken) {
|
||||
headers.Authorization = `Bearer ${trimmedToken}`;
|
||||
}
|
||||
|
||||
const resolvedBaseUrl = resolveNtfyBaseUrl(ntfyBaseUrl);
|
||||
const response = await fetch(`${resolvedBaseUrl}/${topic}`, {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user