diff --git a/e2e/contacts.spec.ts b/e2e/contacts.spec.ts index 528b10e..0a76574 100644 --- a/e2e/contacts.spec.ts +++ b/e2e/contacts.spec.ts @@ -14,23 +14,53 @@ test("shows empty state", async ({ page }) => { await expect(page.getByText("No contacts added yet.")).toBeVisible(); }); -test("adds a contact", async ({ page }) => { +test("adds an email contact and displays it in the list", async ({ page }) => { await page.goto("/settings"); await page.getByPlaceholder("email@example.com").fill("contact@example.com"); await page.getByRole("button", { name: "Add" }).click(); - await expect(page.getByText("contact@example.com")).toBeVisible(); + const contactRow = page.getByRole("listitem").filter({ hasText: "contact@example.com" }); + await expect(contactRow).toBeVisible(); + await expect(contactRow.getByText("email")).toBeVisible(); + await expect(contactRow.getByText("contact@example.com")).toBeVisible(); }); -test("removes a contact", async ({ page, adminClient }) => { - // Seed a contact - await adminClient.from("contacts").insert({ email: "remove-me@example.com" }); +test("adds a Slack contact and displays it in the list", async ({ page }) => { + await page.goto("/settings"); + await page.getByRole("combobox").selectOption("slack"); + await page.getByPlaceholder("#channel name").fill("#alerts"); + await page.getByPlaceholder("https://hooks.slack.com/services/...").fill("https://hooks.slack.com/services/T00/B00/test"); + await page.getByRole("button", { name: "Add" }).click(); + + const contactRow = page.getByRole("listitem").filter({ hasText: "#alerts" }); + await expect(contactRow).toBeVisible(); + await expect(contactRow.getByText("slack")).toBeVisible(); + await expect(contactRow.getByText("#alerts")).toBeVisible(); + await expect(contactRow.getByRole("button", { name: "Remove" })).toBeVisible(); +}); + +test("removes an email contact", async ({ page, adminClient }) => { + await adminClient.from("contacts").insert({ type: "email", email: "remove-me@example.com" }); await page.goto("/settings"); await expect(page.getByText("remove-me@example.com")).toBeVisible(); - // Click the Remove button next to the specific contact const contactRow = page.getByRole("listitem").filter({ hasText: "remove-me@example.com" }); await contactRow.getByRole("button", { name: "Remove" }).click(); await expect(page.getByText("remove-me@example.com")).not.toBeVisible(); }); + +test("removes a Slack contact", async ({ page, adminClient }) => { + await adminClient.from("contacts").insert({ + type: "slack", + webhook_url: "https://hooks.slack.com/services/T00/B00/remove-test", + label: "#ops-alerts", + }); + + await page.goto("/settings"); + const contactRow = page.getByRole("listitem").filter({ hasText: "#ops-alerts" }); + await expect(contactRow).toBeVisible(); + + await contactRow.getByRole("button", { name: "Remove" }).click(); + await expect(page.getByText("#ops-alerts")).not.toBeVisible(); +}); diff --git a/src/__tests__/check-runner.test.ts b/src/__tests__/check-runner.test.ts index 88187b2..ad14c68 100644 --- a/src/__tests__/check-runner.test.ts +++ b/src/__tests__/check-runner.test.ts @@ -11,9 +11,11 @@ vi.mock('@/lib/checker', async (importOriginal) => { }) // Mock the notifications module -const mockSendIncidentAlert = vi.fn() +const mockSendIncidentEmail = vi.fn() +const mockSendIncidentSlack = vi.fn() vi.mock('@/lib/notifications', () => ({ - sendIncidentAlert: (...args: unknown[]) => mockSendIncidentAlert(...args), + sendIncidentEmail: (...args: unknown[]) => mockSendIncidentEmail(...args), + sendIncidentSlack: (...args: unknown[]) => mockSendIncidentSlack(...args), })) // Mock the admin client @@ -30,7 +32,8 @@ import { checkSite } from '@/lib/checker' describe('POST /api/checks/run', () => { beforeEach(() => { vi.clearAllMocks() - mockSendIncidentAlert.mockResolvedValue(undefined) + mockSendIncidentEmail.mockResolvedValue(undefined) + mockSendIncidentSlack.mockResolvedValue(undefined) process.env.CRON_SECRET = 'test-secret' }) @@ -90,7 +93,7 @@ describe('POST /api/checks/run', () => { expect(body).toEqual({ message: 'No sites to check', checks: 0, incidents: 0 }) }) - test('hard failure (HTTP 500) creates incident immediately, sends email', async () => { + test('hard failure creates incident, sends email and Slack alerts', async () => { const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } mockFrom.mockImplementation((table: string) => { @@ -129,7 +132,10 @@ describe('POST /api/checks/run', () => { if (table === 'contacts') { return { select: () => ({ - data: [{ email: 'alice@example.com' }], + data: [ + { type: 'email', email: 'alice@example.com', webhook_url: null }, + { type: 'slack', email: null, webhook_url: 'https://hooks.slack.com/services/T00/B00/xxx' }, + ], }), } } @@ -151,13 +157,20 @@ describe('POST /api/checks/run', () => { const body = await response.json() expect(body.checks).toBe(1) expect(body.incidents).toBe(1) - expect(mockSendIncidentAlert).toHaveBeenCalledWith({ + expect(mockSendIncidentEmail).toHaveBeenCalledWith({ siteName: 'Test', siteUrl: 'https://example.com', error: 'HTTP 500', incidentId: 'inc-1', contactEmails: ['alice@example.com'], }) + expect(mockSendIncidentSlack).toHaveBeenCalledWith({ + siteName: 'Test', + siteUrl: 'https://example.com', + error: 'HTTP 500', + incidentId: 'inc-1', + webhookUrls: ['https://hooks.slack.com/services/T00/B00/xxx'], + }) }) test('single soft failure (HTTP 503) does not create incident', async () => { @@ -217,7 +230,8 @@ describe('POST /api/checks/run', () => { const response = await POST(request) const body = await response.json() expect(body.incidents).toBe(0) - expect(mockSendIncidentAlert).not.toHaveBeenCalled() + expect(mockSendIncidentEmail).not.toHaveBeenCalled() + expect(mockSendIncidentSlack).not.toHaveBeenCalled() }) test('three soft failures within an hour creates incident', async () => { @@ -272,7 +286,7 @@ describe('POST /api/checks/run', () => { if (table === 'contacts') { return { select: () => ({ - data: [{ email: 'alice@example.com' }], + data: [{ type: 'email', email: 'alice@example.com', webhook_url: null }], }), } } @@ -293,7 +307,8 @@ describe('POST /api/checks/run', () => { const response = await POST(request) const body = await response.json() expect(body.incidents).toBe(1) - expect(mockSendIncidentAlert).toHaveBeenCalled() + expect(mockSendIncidentEmail).toHaveBeenCalled() + expect(mockSendIncidentSlack).toHaveBeenCalled() }) test('two soft failures within an hour does not create incident', async () => { @@ -356,7 +371,8 @@ describe('POST /api/checks/run', () => { const response = await POST(request) const body = await response.json() expect(body.incidents).toBe(0) - expect(mockSendIncidentAlert).not.toHaveBeenCalled() + expect(mockSendIncidentEmail).not.toHaveBeenCalled() + expect(mockSendIncidentSlack).not.toHaveBeenCalled() }) test('records checks without creating incidents for successful checks', async () => { @@ -397,7 +413,8 @@ describe('POST /api/checks/run', () => { expect(mockInsert).toHaveBeenCalledWith( expect.objectContaining({ status: 'success', status_code: 200 }) ) - expect(mockSendIncidentAlert).not.toHaveBeenCalled() + expect(mockSendIncidentEmail).not.toHaveBeenCalled() + expect(mockSendIncidentSlack).not.toHaveBeenCalled() }) test('skips rejected promises from Promise.allSettled', async () => { @@ -441,7 +458,7 @@ describe('POST /api/checks/run', () => { expect(body.incidents).toBe(0) }) - test('does not send email for existing open incident', async () => { + test('does not send alerts for existing open incident', async () => { const site = { id: 'site-1', name: 'Test', url: 'https://example.com' } mockFrom.mockImplementation((table: string) => { @@ -489,7 +506,8 @@ describe('POST /api/checks/run', () => { const response = await POST(request) const body = await response.json() expect(body.incidents).toBe(0) - expect(mockSendIncidentAlert).not.toHaveBeenCalled() + expect(mockSendIncidentEmail).not.toHaveBeenCalled() + expect(mockSendIncidentSlack).not.toHaveBeenCalled() }) }) diff --git a/src/__tests__/contacts.test.ts b/src/__tests__/contacts.test.ts index e746a66..28b6cc2 100644 --- a/src/__tests__/contacts.test.ts +++ b/src/__tests__/contacts.test.ts @@ -37,12 +37,12 @@ describe("addContact action", () => { mockGetUser.mockResolvedValue({ data: { user: null } }) const formData = new FormData() - formData.set("email", "test@example.com") + formData.set("contact_email", "test@example.com") await expect(addContact(formData)).rejects.toThrow("NEXT_REDIRECT:/login") }) - test("inserts contact for authenticated users", async () => { + test("inserts email contact for authenticated users", async () => { mockGetUser.mockResolvedValue({ data: { user: { id: "user-1" } }, }) @@ -51,11 +51,47 @@ describe("addContact action", () => { mockFrom.mockReturnValue({ insert: mockInsert }) const formData = new FormData() - formData.set("email", "test@example.com") + formData.set("contact_type", "email") + formData.set("contact_email", "test@example.com") await addContact(formData) expect(mockFrom).toHaveBeenCalledWith("contacts") - expect(mockInsert).toHaveBeenCalledWith({ email: "test@example.com" }) + expect(mockInsert).toHaveBeenCalledWith({ type: "email", email: "test@example.com" }) + }) + + test("inserts Slack contact for authenticated users", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockInsert = vi.fn().mockReturnValue({ error: null }) + mockFrom.mockReturnValue({ insert: mockInsert }) + + const formData = new FormData() + formData.set("contact_type", "slack") + formData.set("contact_label", "#alerts") + formData.set("contact_webhook_url", "https://hooks.slack.com/services/T00/B00/xxx") + + await addContact(formData) + expect(mockFrom).toHaveBeenCalledWith("contacts") + expect(mockInsert).toHaveBeenCalledWith({ + type: "slack", + webhook_url: "https://hooks.slack.com/services/T00/B00/xxx", + label: "#alerts", + }) + }) + + test("rejects invalid Slack webhook URL", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const formData = new FormData() + formData.set("contact_type", "slack") + formData.set("contact_webhook_url", "https://example.com/not-slack") + + await addContact(formData) + expect(mockFrom).not.toHaveBeenCalled() }) test("does nothing if email is empty", async () => { @@ -64,9 +100,49 @@ describe("addContact action", () => { }) const formData = new FormData() + formData.set("contact_type", "email") + await addContact(formData) + expect(mockFrom).not.toHaveBeenCalled() + }) + + test("does nothing if webhook URL is empty for Slack", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const formData = new FormData() + formData.set("contact_type", "slack") + formData.set("contact_label", "#alerts") await addContact(formData) expect(mockFrom).not.toHaveBeenCalled() }) + + test("does nothing if label is empty for Slack", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const formData = new FormData() + formData.set("contact_type", "slack") + formData.set("contact_webhook_url", "https://hooks.slack.com/services/T00/B00/xxx") + await addContact(formData) + expect(mockFrom).not.toHaveBeenCalled() + }) + + test("defaults to email type when contact_type is not set", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockInsert = vi.fn().mockReturnValue({ error: null }) + mockFrom.mockReturnValue({ insert: mockInsert }) + + const formData = new FormData() + formData.set("contact_email", "test@example.com") + + await addContact(formData) + expect(mockInsert).toHaveBeenCalledWith({ type: "email", email: "test@example.com" }) + }) }) describe("deleteContact action", () => { diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts index 81e36f7..4a174b8 100644 --- a/src/__tests__/notifications.test.ts +++ b/src/__tests__/notifications.test.ts @@ -8,16 +8,20 @@ vi.mock("resend", () => ({ }, })) -import { sendIncidentAlert } from "@/lib/notifications" +// Mock fetch for Slack +const mockFetch = vi.fn() +vi.stubGlobal("fetch", mockFetch) -describe("sendIncidentAlert", () => { +import { sendIncidentEmail, sendIncidentSlack } from "@/lib/notifications" + +describe("sendIncidentEmail", () => { beforeEach(() => { vi.clearAllMocks() mockSend.mockResolvedValue({ id: "email-1" }) }) test("sends email to all contacts with correct content", async () => { - await sendIncidentAlert({ + await sendIncidentEmail({ siteName: "Main Website", siteUrl: "https://example.com", error: "HTTP 503", @@ -37,7 +41,7 @@ describe("sendIncidentAlert", () => { }) test("does nothing when contactEmails is empty", async () => { - await sendIncidentAlert({ + await sendIncidentEmail({ siteName: "Main Website", siteUrl: "https://example.com", error: "HTTP 503", @@ -52,7 +56,7 @@ describe("sendIncidentAlert", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) mockSend.mockRejectedValue(new Error("API rate limit")) - await sendIncidentAlert({ + await sendIncidentEmail({ siteName: "Main Website", siteUrl: "https://example.com", error: "HTTP 503", @@ -68,7 +72,7 @@ describe("sendIncidentAlert", () => { }) test("works without error field", async () => { - await sendIncidentAlert({ + await sendIncidentEmail({ siteName: "Main Website", siteUrl: "https://example.com", error: null, @@ -80,3 +84,120 @@ describe("sendIncidentAlert", () => { expect(call.html).not.toContain("Error:") }) }) + +describe("sendIncidentSlack", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("posts to all Slack webhooks with correct content", async () => { + mockFetch.mockResolvedValue({ ok: true }) + + await sendIncidentSlack({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: "HTTP 503", + incidentId: "inc-1", + webhookUrls: ["https://hooks.slack.com/services/T00/B00/xxx"], + }) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe("https://hooks.slack.com/services/T00/B00/xxx") + expect(options.method).toBe("POST") + + const body = JSON.parse(options.body) + expect(body.text).toContain("Site status alert: new incident") + expect(body.text).toContain("*Main Website* is down") + expect(body.text).toContain("https://example.com") + expect(body.text).toContain("HTTP 503") + expect(body.text).toContain("/incidents/inc-1") + }) + + test("sends to multiple webhooks", async () => { + mockFetch.mockResolvedValue({ ok: true }) + + await sendIncidentSlack({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: null, + incidentId: "inc-1", + webhookUrls: [ + "https://hooks.slack.com/services/T00/B00/aaa", + "https://hooks.slack.com/services/T00/B00/bbb", + ], + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch.mock.calls[0][0]).toBe("https://hooks.slack.com/services/T00/B00/aaa") + expect(mockFetch.mock.calls[1][0]).toBe("https://hooks.slack.com/services/T00/B00/bbb") + }) + + test("does nothing when webhookUrls is empty", async () => { + await sendIncidentSlack({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: null, + incidentId: "inc-1", + webhookUrls: [], + }) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + test("handles fetch failure gracefully and continues", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockFetch + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ ok: true }) + + await sendIncidentSlack({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: null, + incidentId: "inc-1", + webhookUrls: [ + "https://hooks.slack.com/services/T00/B00/aaa", + "https://hooks.slack.com/services/T00/B00/bbb", + ], + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to send Slack alert:", + expect.any(Error) + ) + consoleSpy.mockRestore() + }) + + test("logs error when webhook returns non-ok status", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockFetch.mockResolvedValue({ ok: false, status: 403 }) + + await sendIncidentSlack({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: null, + incidentId: "inc-1", + webhookUrls: ["https://hooks.slack.com/services/T00/B00/xxx"], + }) + + expect(consoleSpy).toHaveBeenCalledWith("Slack webhook returned", 403) + consoleSpy.mockRestore() + }) + + test("omits error line when error is null", async () => { + mockFetch.mockResolvedValue({ ok: true }) + + await sendIncidentSlack({ + siteName: "Main Website", + siteUrl: "https://example.com", + error: null, + incidentId: "inc-1", + webhookUrls: ["https://hooks.slack.com/services/T00/B00/xxx"], + }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.text).not.toContain("Error:") + }) +}) diff --git a/src/app/api/checks/run/route.ts b/src/app/api/checks/run/route.ts index d70f136..0355f54 100644 --- a/src/app/api/checks/run/route.ts +++ b/src/app/api/checks/run/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { createAdminClient } from '@/lib/supabase/admin' import { checkSite, isSoftFailure } from '@/lib/checker' -import { sendIncidentAlert } from '@/lib/notifications' +import { sendIncidentEmail, sendIncidentSlack } from '@/lib/notifications' export const maxDuration = 60 @@ -95,15 +95,32 @@ async function handleCheckRun(request: NextRequest) { if (incident) { const { data: contacts } = await supabase .from('contacts') - .select('email') - - await sendIncidentAlert({ - siteName: site.name, - siteUrl: site.url, - error, - incidentId: incident.id, - contactEmails: (contacts ?? []).map((c: { email: string }) => c.email), - }) + .select('type, email, webhook_url') + + const allContacts = contacts ?? [] + const emailAddresses = allContacts + .filter((c: { type: string }) => c.type === 'email') + .map((c: { email: string }) => c.email) + const webhookUrls = allContacts + .filter((c: { type: string }) => c.type === 'slack') + .map((c: { webhook_url: string }) => c.webhook_url) + + await Promise.all([ + sendIncidentEmail({ + siteName: site.name, + siteUrl: site.url, + error, + incidentId: incident.id, + contactEmails: emailAddresses, + }), + sendIncidentSlack({ + siteName: site.name, + siteUrl: site.url, + error, + incidentId: incident.id, + webhookUrls, + }), + ]) } } } diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 1fcab26..8953a38 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -4,6 +4,8 @@ import { revalidatePath } from "next/cache" import { redirect } from "next/navigation" import { createClient } from "@/lib/supabase/server" +const SLACK_WEBHOOK_REGEX = /^https:\/\/hooks\.slack\.com\/services\/.+$/ + export async function addContact(formData: FormData) { const supabase = await createClient() const { @@ -12,10 +14,22 @@ export async function addContact(formData: FormData) { if (!user) redirect("/login") - const email = formData.get("email") as string - if (!email) return + const type = (formData.get("contact_type") as string) || "email" + + if (type === "slack") { + const webhookUrl = formData.get("contact_webhook_url") as string + const label = formData.get("contact_label") as string + if (!webhookUrl || !label) return + if (!SLACK_WEBHOOK_REGEX.test(webhookUrl)) return + const { error } = await supabase.from("contacts").insert({ type: "slack", webhook_url: webhookUrl, label }) + if (error) console.error("Failed to insert Slack contact:", error) + } else { + const email = formData.get("contact_email") as string + if (!email) return + const { error } = await supabase.from("contacts").insert({ type: "email", email }) + if (error) console.error("Failed to insert email contact:", error) + } - await supabase.from("contacts").insert({ email }) revalidatePath("/settings") } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 332138e..e87ab8c 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,7 +1,8 @@ import { redirect } from "next/navigation" import { createClient } from "@/lib/supabase/server" import type { Contact, Invitation } from "@/lib/supabase/types" -import { addContact, deleteContact } from "./actions" +import { deleteContact } from "./actions" +import AddContactForm from "@/components/AddContactForm" import { sendInvitation, revokeInvitation } from "./invite-actions" import Link from "next/link" @@ -54,7 +55,7 @@ export default async function SettingsPage() { Contacts
- All contacts are notified by email when an incident is opened. + All contacts are notified when an incident is opened.