From b509e01f178bf8783570cee2e4dfdb8dfef90a81 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Mon, 14 Sep 2026 23:50:55 +0000 Subject: [PATCH 01/13] chore: bump go_notify_yourself to v0.3.0 Pulls in the providers/webpush package (RFC 8030/8291/8292 direct browser Web Push, no third-party relay) needed for the upcoming Web Push notification provider. go.sum diff reviewed: only the version line for this module changes, no new transitive dependencies. Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN --- backend/go.mod | 2 +- backend/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 66e808c4e..ab1e51b9e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,7 +4,7 @@ go 1.27.1 require ( filippo.io/age v1.3.2 - github.com/Wikid82/go_notify_yourself v0.2.2 + github.com/Wikid82/go_notify_yourself v0.3.0 github.com/gin-contrib/gzip v1.2.7 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 diff --git a/backend/go.sum b/backend/go.sum index e69d6365e..f6c8fa9d7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,8 +6,8 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Wikid82/go_notify_yourself v0.2.2 h1:ujRRpZi2xC7MUJpvBDx67ZbQW+9wqdX6RFxkY6uUZxk= -github.com/Wikid82/go_notify_yourself v0.2.2/go.mod h1:89ATcddEmn4OWi6m0SUKXJ6GWHHQ5epbchgPdIpt+I8= +github.com/Wikid82/go_notify_yourself v0.3.0 h1:+bQQbzLYpuKkWi0P9RsU2RX4j5nPMZ+ce6qns+KCYMk= +github.com/Wikid82/go_notify_yourself v0.3.0/go.mod h1:89ATcddEmn4OWi6m0SUKXJ6GWHHQ5epbchgPdIpt+I8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= From fbe022aa022d2ea57e4b75b42a000bad3fe33944 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Mon, 14 Sep 2026 23:55:43 +0000 Subject: [PATCH 02/13] test: add e2e specs for web push subscribe/unsubscribe flow (fixme) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encodes the Web Push notification provider's intended behavior as test.fixme specs against the approved spec's API contracts (§3.4) and frontend design (§3.6), before any backend/frontend implementation lands. Covers admin-only provisioning, per-device subscribe/unsubscribe (with the browser Push API stubbed via page.addInitScript, since real push delivery cannot be exercised in CI), and per-event-type toggle regression for the new provider type. Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN --- tests/e2e/notifications-webpush.spec.ts | 493 ++++++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 tests/e2e/notifications-webpush.spec.ts diff --git a/tests/e2e/notifications-webpush.spec.ts b/tests/e2e/notifications-webpush.spec.ts new file mode 100644 index 000000000..2d6b5b29e --- /dev/null +++ b/tests/e2e/notifications-webpush.spec.ts @@ -0,0 +1,493 @@ +/** + * Web Push Notification Provider E2E Tests + * + * Phase 1 (docs/plans/current_spec.md §5, §9 commit 1): encodes the intended + * Web Push provider behavior against the API contracts (§3.4) and frontend + * design (§3.6) before any backend/frontend code for this feature exists. + * All tests below are `test.fixme` and are flipped to live tests in commit 8 + * (§9), once commits 2-7 (backend model/dispatch/handlers, frontend service + * worker/API client/UI) have landed. + * + * Scenarios covered: + * - Provisioning a Web Push provider from the Notifications page, and that + * provisioning is admin-only (§3.4.0, §3.4.1). + * - Subscribing this device (§3.4.2 VAPID key, §3.4.3 subscribe), mocking + * `Notification`/`navigator.serviceWorker`/`PushManager` via + * `page.addInitScript` — real push delivery cannot be exercised in CI + * (spec §5 Phase 1, §6 Acceptance Criteria #3). + * - Unsubscribing removes the device from the subscriptions list (§3.4.5). + * - Per-event-type `NotifyXxx` toggles persist for a `webpush` provider row + * exactly like every other provider type (regression coverage for the + * generic preference UI, §3.6.3's closing note). + * + * See docs/plans/current_spec.md §3.4 (API contracts), §3.4.0 (authorization + * model), §3.6 (frontend design), §9 commit 1. + */ + +import { test, expect, loginUser } from '../fixtures/auth-fixtures'; +import { waitForLoadingComplete } from '../utils/wait-helpers'; +import type { Page } from '@playwright/test'; + +const WEBPUSH_BASE = '/api/v1/notifications/providers/webpush'; +const PROVIDERS_ENDPOINT = '/api/v1/notifications/providers'; +const MOCK_ENDPOINT = 'https://fcm.googleapis.com/fcm/send/mock-endpoint-e2e'; +const MOCK_VAPID_PUBLIC_KEY = 'BN_mock_vapid_public_key_0123456789'; + +/** §3.4.1 response shape for a provisioned `webpush` provider row. */ +interface WebPushProviderFixture { + id: string; + name: string; + type: 'webpush'; + enabled: boolean; + service_config: string; + notify_proxy_hosts: boolean; + notify_remote_servers: boolean; + notify_domains: boolean; + notify_certs: boolean; + notify_uptime: boolean; +} + +function buildWebPushProviderFixture( + overrides: Partial = {} +): WebPushProviderFixture { + return { + id: 'webpush-provider-1', + name: 'Browser Push', + type: 'webpush', + enabled: true, + service_config: JSON.stringify({ + vapid_public_key: MOCK_VAPID_PUBLIC_KEY, + vapid_subject: 'mailto:admin@example.com', + }), + notify_proxy_hosts: true, + notify_remote_servers: false, + notify_domains: false, + notify_certs: true, + notify_uptime: false, + ...overrides, + }; +} + +/** §3.4.4 response shape for one subscribed device. */ +interface WebPushSubscriptionFixture { + id: string; + endpoint: string; + user_agent: string; + created_at: string; + last_seen_at: string; +} + +function buildSubscriptionFixture( + overrides: Partial = {} +): WebPushSubscriptionFixture { + return { + id: 'webpush-sub-1', + endpoint: MOCK_ENDPOINT, + user_agent: 'Mozilla/5.0 (E2E Test Runner)', + created_at: '2026-09-01T00:00:00Z', + last_seen_at: '2026-09-01T00:00:00Z', + ...overrides, + }; +} + +/** + * Stubs the browser-side Push API (`Notification`, `navigator.serviceWorker`, + * `PushManager`) so `Notification.requestPermission()` and + * `registration.pushManager.subscribe()` resolve deterministically, without a + * real OS permission prompt or a real round trip to a push service. Real push + * delivery cannot be exercised in CI (spec §5 Phase 1) — this stub is what + * lets the subscribe/unsubscribe flow be driven end-to-end anyway. + */ +async function stubBrowserPushApis(page: Page): Promise { + await page.addInitScript((mockEndpoint: string) => { + const mockSubscription = { + endpoint: mockEndpoint, + toJSON() { + return { + endpoint: mockEndpoint, + keys: { p256dh: 'mock-p256dh-key', auth: 'mock-auth-secret' }, + }; + }, + unsubscribe: async () => true, + }; + + const mockRegistration = { + pushManager: { + subscribe: async () => mockSubscription, + getSubscription: async () => null, + }, + }; + + class MockNotification { + static permission = 'default'; + static requestPermission = async () => { + MockNotification.permission = 'granted'; + return 'granted'; + }; + } + + Object.defineProperty(window, 'Notification', { + configurable: true, + value: MockNotification, + }); + + Object.defineProperty(window.navigator, 'serviceWorker', { + configurable: true, + value: { + register: async () => mockRegistration, + ready: Promise.resolve(mockRegistration), + }, + }); + }, MOCK_ENDPOINT); +} + +/** Locator matching the §3.6.3 "Enable push notifications on this device" + * control, tolerant of either a checkbox (matching every other toggle in + * this form) or a switch-styled control, since the exact implementation + * hasn't landed yet. */ +function deviceToggleLocator(page: Page) { + return page + .getByRole('checkbox', { name: /enable push notifications on this device/i }) + .or(page.getByRole('switch', { name: /enable push notifications on this device/i })); +} + +test.describe('Web Push Notification Provider', () => { + test.describe('Provisioning (§3.4.1, admin-only per §3.4.0)', () => { + test.fixme( + 'admin can provision a Web Push provider from the Notifications page', + async ({ page, adminUser }) => { + await loginUser(page, adminUser); + + let capturedPayload: Record | null = null; + let providers: WebPushProviderFixture[] = []; + const provisioned = buildWebPushProviderFixture(); + + await test.step('Mock the provision endpoint and the provider list', async () => { + await page.route(`**${WEBPUSH_BASE}/provision`, async (route) => { + if (route.request().method() === 'POST') { + capturedPayload = route.request().postDataJSON(); + providers = [provisioned]; + await route.fulfill({ status: 201, json: provisioned }); + } else { + await route.continue(); + } + }); + + await page.route(`**${PROVIDERS_ENDPOINT}`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: providers }); + } else { + await route.continue(); + } + }); + }); + + await page.goto('/settings/notifications'); + await waitForLoadingComplete(page); + + await test.step('Open Add Provider form and select Web Push', async () => { + await page.getByRole('button', { name: /add.*provider/i }).click(); + await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 }); + await page.getByTestId('provider-type').selectOption('webpush'); + }); + + await test.step('Verify the dedicated provisioning flow replaces the generic URL/token fields (§3.6.3)', async () => { + await expect(page.getByTestId('provider-url')).toHaveCount(0); + await expect(page.getByTestId('provider-gotify-token')).toHaveCount(0); + + const provisionButton = page.getByRole('button', { name: /provision web push/i }); + await expect(provisionButton).toBeVisible(); + await expect(provisionButton).toMatchAriaSnapshot(` + - button "Provision Web Push" + `); + }); + + await test.step('Fill provisioning details and provision', async () => { + await page.getByTestId('provider-name').fill('Browser Push'); + await page.getByLabel(/vapid subject/i).fill('mailto:admin@example.com'); + + await Promise.all([ + page.waitForResponse( + (resp) => resp.url().includes(`${WEBPUSH_BASE}/provision`) && resp.status() === 201 + ), + page.getByRole('button', { name: /provision web push/i }).click(), + ]); + }); + + await test.step('Verify the outgoing payload matches the §3.4.1 contract', () => { + expect(capturedPayload).toBeTruthy(); + expect(capturedPayload?.name).toBe('Browser Push'); + expect(capturedPayload?.vapid_subject).toBe('mailto:admin@example.com'); + }); + + await test.step('Verify the provisioned provider appears in the list', async () => { + const row = page.getByTestId(`provider-row-${provisioned.id}`); + await expect(row).toBeVisible({ timeout: 10000 }); + await expect(row).toContainText('Browser Push'); + }); + } + ); + + test.fixme( + 'a non-admin user is forbidden from provisioning a Web Push provider', + async ({ page, regularUser }) => { + await loginUser(page, regularUser); + + await test.step('Call the provision endpoint directly as a RoleUser (management access, not admin)', async () => { + const response = await page.request.post(`${WEBPUSH_BASE}/provision`, { + headers: { Authorization: `Bearer ${regularUser.token}` }, + data: { name: 'Browser Push', vapid_subject: 'mailto:admin@example.com' }, + }); + + // §3.4 row 1: provisioning additionally requires RequireRole(admin), + // mirroring Test/Preview's existing admin-only pattern. + expect(response.status()).toBe(403); + }); + } + ); + }); + + test.describe('Device subscription (§3.4.2 VAPID key, §3.4.3 subscribe)', () => { + test.fixme( + 'an authenticated user can subscribe this device to Web Push notifications', + async ({ page, regularUser }) => { + await stubBrowserPushApis(page); + await loginUser(page, regularUser); + + let subscriptions: WebPushSubscriptionFixture[] = []; + let capturedSubscribePayload: Record | null = null; + + await test.step('Mock the provisioned provider, VAPID key, and subscriptions endpoints', async () => { + await page.route(`**${PROVIDERS_ENDPOINT}`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: [buildWebPushProviderFixture()] }); + } else { + await route.continue(); + } + }); + + await page.route(`**${WEBPUSH_BASE}/vapid-public-key`, async (route) => { + await route.fulfill({ status: 200, json: { vapid_public_key: MOCK_VAPID_PUBLIC_KEY } }); + }); + + await page.route(`**${WEBPUSH_BASE}/subscriptions`, async (route) => { + if (route.request().method() === 'POST') { + capturedSubscribePayload = route.request().postDataJSON(); + const created = buildSubscriptionFixture(); + subscriptions = [created]; + await route.fulfill({ + status: 201, + json: { id: created.id, endpoint: created.endpoint }, + }); + } else if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: subscriptions }); + } else { + await route.continue(); + } + }); + }); + + await page.goto('/settings/notifications'); + await waitForLoadingComplete(page); + + await test.step('Enable push notifications on this device', async () => { + await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes(`${WEBPUSH_BASE}/subscriptions`) && + resp.request().method() === 'POST' && + resp.status() === 201 + ), + deviceToggleLocator(page).click(), + ]); + }); + + await test.step('Verify the browser PushSubscription is forwarded verbatim (§3.4.3)', () => { + expect(capturedSubscribePayload).toBeTruthy(); + expect(capturedSubscribePayload?.endpoint).toBe(MOCK_ENDPOINT); + const keys = capturedSubscribePayload?.keys as Record; + expect(keys?.p256dh).toBe('mock-p256dh-key'); + expect(keys?.auth).toBe('mock-auth-secret'); + }); + + await test.step("Verify the device now appears in this user's subscribed devices list", async () => { + await expect(page.getByText(MOCK_ENDPOINT).or(page.getByText(/this device/i)).first()).toBeVisible({ + timeout: 10000, + }); + }); + } + ); + }); + + test.describe('Device unsubscription (§3.4.5)', () => { + test.fixme( + 'unsubscribing removes the device from the subscriptions list', + async ({ page, regularUser }) => { + await stubBrowserPushApis(page); + await loginUser(page, regularUser); + + let subscriptions: WebPushSubscriptionFixture[] = [buildSubscriptionFixture()]; + let deleteCalled = false; + + await test.step("Mock the provisioned provider and this device's existing subscription", async () => { + await page.route(`**${PROVIDERS_ENDPOINT}`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: [buildWebPushProviderFixture()] }); + } else { + await route.continue(); + } + }); + + await page.route(`**${WEBPUSH_BASE}/subscriptions`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: subscriptions }); + } else { + await route.continue(); + } + }); + + await page.route(`**${WEBPUSH_BASE}/subscriptions/*`, async (route) => { + if (route.request().method() === 'DELETE') { + deleteCalled = true; + subscriptions = []; + await route.fulfill({ status: 204 }); + } else { + await route.continue(); + } + }); + }); + + await page.goto('/settings/notifications'); + await waitForLoadingComplete(page); + + await test.step('Verify the subscribed device is listed', async () => { + await expect(page.getByText(MOCK_ENDPOINT).or(page.getByText(/this device/i)).first()).toBeVisible({ + timeout: 10000, + }); + }); + + await test.step('Verify the enabled toggle reflects the existing subscription', async () => { + await expect(deviceToggleLocator(page)).toMatchAriaSnapshot(` + - checkbox "Enable push notifications on this device" [checked] + `); + }); + + await test.step('Unsubscribe this device', async () => { + await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes(`${WEBPUSH_BASE}/subscriptions/`) && + resp.request().method() === 'DELETE' && + resp.status() === 204 + ), + deviceToggleLocator(page).click(), + ]); + }); + + await test.step('Verify the device is removed from the list and the backend delete fired (§3.4.5)', async () => { + expect(deleteCalled).toBe(true); + await expect(page.getByText(MOCK_ENDPOINT)).toHaveCount(0); + }); + } + ); + }); + + test.describe('Per-event-type toggle regression (§3.4.0 closing note, §3.6.3)', () => { + test.fixme( + 'per-event-type notification toggles persist for a webpush provider row identically to other provider types', + async ({ page, adminUser }) => { + await loginUser(page, adminUser); + + let updatedPayload: Record | null = null; + let providers: WebPushProviderFixture[] = [ + buildWebPushProviderFixture({ + notify_proxy_hosts: true, + notify_remote_servers: false, + notify_domains: false, + notify_certs: true, + notify_uptime: false, + }), + ]; + + await test.step('Mock the existing webpush provider row', async () => { + await page.route(`**${PROVIDERS_ENDPOINT}`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: providers }); + } else { + await route.continue(); + } + }); + + await page.route(`**${PROVIDERS_ENDPOINT}/*`, async (route) => { + if (route.request().method() === 'PUT') { + updatedPayload = route.request().postDataJSON(); + providers = providers.map((p) => + p.id === 'webpush-provider-1' ? { ...p, ...updatedPayload } : p + ); + await route.fulfill({ status: 200, json: { success: true } }); + } else { + await route.continue(); + } + }); + }); + + await page.goto('/settings/notifications'); + await waitForLoadingComplete(page); + + await test.step('Open the webpush provider row for editing', async () => { + const providerRow = page.getByTestId('provider-row-webpush-provider-1'); + await expect(providerRow).toBeVisible({ timeout: 10000 }); + await providerRow.getByRole('button', { name: /edit/i }).click(); + await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 }); + }); + + await test.step('Verify the loaded toggle state matches the mocked webpush row', async () => { + await expect(page.getByTestId('notify-proxy-hosts')).toBeChecked(); + await expect(page.getByTestId('notify-remote-servers')).not.toBeChecked(); + await expect(page.getByTestId('notify-domains')).not.toBeChecked(); + await expect(page.getByTestId('notify-certs')).toBeChecked(); + await expect(page.getByTestId('notify-uptime')).not.toBeChecked(); + }); + + await test.step('Flip every event-type toggle to its opposite state', async () => { + await page.getByTestId('notify-proxy-hosts').uncheck(); + await page.getByTestId('notify-remote-servers').check(); + await page.getByTestId('notify-domains').check(); + await page.getByTestId('notify-certs').uncheck(); + await page.getByTestId('notify-uptime').check(); + }); + + await test.step('Save and verify the PUT payload persists every toggle for the webpush type', async () => { + await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes(`${PROVIDERS_ENDPOINT}/webpush-provider-1`) && + resp.request().method() === 'PUT' && + resp.status() === 200 + ), + page.getByTestId('provider-save-btn').click(), + ]); + + expect(updatedPayload?.notify_proxy_hosts).toBe(false); + expect(updatedPayload?.notify_remote_servers).toBe(true); + expect(updatedPayload?.notify_domains).toBe(true); + expect(updatedPayload?.notify_certs).toBe(false); + expect(updatedPayload?.notify_uptime).toBe(true); + }); + + await test.step('Reload and verify the flipped toggles survive a refetch, exactly like every other provider type', async () => { + await page.reload(); + await waitForLoadingComplete(page); + + const providerRow = page.getByTestId('provider-row-webpush-provider-1'); + await providerRow.getByRole('button', { name: /edit/i }).click(); + await expect(page.getByTestId('notify-proxy-hosts')).not.toBeChecked(); + await expect(page.getByTestId('notify-remote-servers')).toBeChecked(); + await expect(page.getByTestId('notify-domains')).toBeChecked(); + await expect(page.getByTestId('notify-certs')).not.toBeChecked(); + await expect(page.getByTestId('notify-uptime')).toBeChecked(); + }); + } + ); + }); +}); From 94da71a53ef6b4b166f986d7f511f26d72e14ad7 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Mon, 14 Sep 2026 23:56:52 +0000 Subject: [PATCH 03/13] feat: add WebPushSubscription model, migration, and singleton index Adds the child table holding each browser's Web Push destination (FK to NotificationProvider, Type="webpush"), registers it in AutoMigrate, and creates the idx_webpush_singleton partial unique index (CREATE UNIQUE INDEX ... WHERE type='webpush') immediately after AutoMigrate to close a race condition where two concurrent provisioning requests could otherwise create two independent VAPID identities (service-layer COUNT-then-INSERT alone is not atomic under SQLite's single-writer connection pool). Includes a concurrency regression test firing two simultaneous INSERTs against the index and asserting exactly one succeeds. Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN --- backend/internal/api/routes/routes.go | 11 ++ .../internal/models/webpush_subscription.go | 44 +++++ .../models/webpush_subscription_test.go | 176 ++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 backend/internal/models/webpush_subscription.go create mode 100644 backend/internal/models/webpush_subscription_test.go diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 3322a979f..80a6a33e9 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -123,6 +123,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg &models.ImportSession{}, &models.Notification{}, &models.NotificationProvider{}, + &models.WebPushSubscription{}, // Web Push subscriptions — FK to NotificationProvider (Type="webpush") &models.NotificationTemplate{}, &models.NotificationConfig{}, &models.UptimeMonitor{}, @@ -154,6 +155,16 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg return uptimeShutdown, fmt.Errorf("auto migrate: %w", err) } + // Enforce the Web Push provider singleton invariant at the database + // level — a service-layer COUNT-then-INSERT check alone is not atomic + // under concurrent requests (see docs/plans/current_spec.md §3.1). + // IF NOT EXISTS makes this idempotent across restarts, matching every + // other startup migration step in this function. + if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_webpush_singleton + ON notification_providers(type) WHERE type = 'webpush'`).Error; err != nil { + return uptimeShutdown, fmt.Errorf("create webpush singleton index: %w", err) + } + migrateViewerToPassthrough(db) // Seed the default SecurityConfig row on every startup (idempotent). diff --git a/backend/internal/models/webpush_subscription.go b/backend/internal/models/webpush_subscription.go new file mode 100644 index 000000000..4e908f4e6 --- /dev/null +++ b/backend/internal/models/webpush_subscription.go @@ -0,0 +1,44 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// WebPushSubscription is one browser/device's Web Push destination, +// created when an authenticated Charon user's browser completes +// PushManager.subscribe() and POSTs the resulting PushSubscription to the +// backend. Each row is fanned out to individually by +// NotificationService.dispatchWebPushViaNotify (one webpush.Client per +// row), all sharing the parent NotificationProvider's VAPID identity. +type WebPushSubscription struct { + ID string `gorm:"primaryKey" json:"id"` + ProviderID string `gorm:"index;not null" json:"provider_id"` // FK -> NotificationProvider.ID (Type="webpush") + UserID string `gorm:"index;not null" json:"user_id"` // FK -> User.ID; owner, for scoped unsubscribe + + // PushSubscription destination (from the browser's PushSubscription + // object; see webpush.Config's matching field doc comments). + Endpoint string `gorm:"uniqueIndex;type:text;not null" json:"endpoint"` + P256dh string `gorm:"type:text;not null" json:"-"` // subscriber DH public key; not attacker-sensitive but never needed client-side after registration + Auth string `gorm:"type:text;not null" json:"-"` // subscriber auth secret; same rationale + + // Display/diagnostic metadata, not used for dispatch. + UserAgent string `json:"user_agent,omitempty" gorm:"type:text"` + + // Pruning bookkeeping (dispatch fan-out — notify_webpush_adapter.go). + LastSeenAt time.Time `json:"last_seen_at"` + LastFailureAt *time.Time `json:"last_failure_at,omitempty"` + FailureCount int `json:"failure_count" gorm:"default:0"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *WebPushSubscription) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == "" { + s.ID = uuid.New().String() + } + return +} diff --git a/backend/internal/models/webpush_subscription_test.go b/backend/internal/models/webpush_subscription_test.go new file mode 100644 index 000000000..b0cf28c89 --- /dev/null +++ b/backend/internal/models/webpush_subscription_test.go @@ -0,0 +1,176 @@ +package models + +import ( + "encoding/json" + "path/filepath" + "sync" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupWebPushSubscriptionTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&NotificationProvider{}, &WebPushSubscription{})) + return db +} + +func TestWebPushSubscription_BeforeCreate_GeneratesUUID(t *testing.T) { + db := setupWebPushSubscriptionTestDB(t) + + sub := &WebPushSubscription{ + ProviderID: "provider-1", + UserID: "user-1", + Endpoint: "https://fcm.googleapis.com/fcm/send/abc", + P256dh: "p256dh-value", + Auth: "auth-value", + } + require.NoError(t, db.Create(sub).Error) + + assert.NotEmpty(t, sub.ID, "ID should be set after create") + assert.Len(t, sub.ID, 36, "UUID should be 36 characters") +} + +func TestWebPushSubscription_BeforeCreate_PreservesExistingID(t *testing.T) { + db := setupWebPushSubscriptionTestDB(t) + + sub := &WebPushSubscription{ + ID: "explicit-id", + ProviderID: "provider-1", + UserID: "user-1", + Endpoint: "https://fcm.googleapis.com/fcm/send/def", + P256dh: "p256dh-value", + Auth: "auth-value", + } + require.NoError(t, db.Create(sub).Error) + + assert.Equal(t, "explicit-id", sub.ID) +} + +// TestWebPushSubscription_EndpointUniqueIndex asserts a second row with the +// same Endpoint is rejected at the database level (§3.3.2 design note: the +// same browser re-subscribing must not create duplicate rows that both +// receive the same push). +func TestWebPushSubscription_EndpointUniqueIndex(t *testing.T) { + db := setupWebPushSubscriptionTestDB(t) + + endpoint := "https://fcm.googleapis.com/fcm/send/duplicate" + first := &WebPushSubscription{ + ProviderID: "provider-1", + UserID: "user-1", + Endpoint: endpoint, + P256dh: "p256dh-a", + Auth: "auth-a", + } + require.NoError(t, db.Create(first).Error) + + second := &WebPushSubscription{ + ProviderID: "provider-1", + UserID: "user-2", + Endpoint: endpoint, + P256dh: "p256dh-b", + Auth: "auth-b", + } + err := db.Create(second).Error + require.Error(t, err, "duplicate endpoint should be rejected by the unique index") +} + +// TestWebPushSubscription_FieldsNeverExposeJSON asserts P256dh/Auth are +// never round-tripped to the frontend (json:"-"), per §3.3.2's +// defense-in-depth rationale — this pins the struct tag contract with a +// regression test rather than relying on code review alone to catch a +// future accidental removal of json:"-". +func TestWebPushSubscription_FieldsNeverExposeJSON(t *testing.T) { + // This is a compile-time-adjacent assertion: marshal a populated struct + // and confirm P256dh/Auth values never appear in the output. + sub := WebPushSubscription{ + ID: "id-1", + ProviderID: "provider-1", + UserID: "user-1", + Endpoint: "https://example.com/endpoint", + P256dh: "super-secret-p256dh", + Auth: "super-secret-auth", + } + b, err := json.Marshal(sub) + require.NoError(t, err) + assert.NotContains(t, string(b), "super-secret-p256dh") + assert.NotContains(t, string(b), "super-secret-auth") +} + +// TestWebPushSingletonIndex_ConcurrentInsertsProduceExactlyOneWinner is the +// regression test for the exact race Supervisor identified (see +// docs/plans/current_spec.md §3.1 "Enforcement" and §3.3.4): a +// service-layer COUNT-then-INSERT check alone is not atomic under +// concurrent requests. This test creates the same +// idx_webpush_singleton partial unique index routes.go creates at startup, +// fires two goroutines each inserting a NotificationProvider{Type: +// "webpush"} row against the same *gorm.DB (mirroring the single-writer +// SQLite connection pool production uses, database.go's +// SetMaxOpenConns(1)), and asserts exactly one INSERT succeeds while the +// other fails with a unique-constraint-violation error. It must fail +// against pre-fix (service-layer-COUNT-only) code, since bypassing the +// index entirely would let both concurrent INSERTs succeed. +func TestWebPushSingletonIndex_ConcurrentInsertsProduceExactlyOneWinner(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "webpush_singleton_test.db") + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + // Mirror production's single-writer-connection SQLite pool + // (internal/database/database.go) so concurrent goroutines actually + // interleave their statements on one connection, exercising the same + // race window the index is designed to close. + sqlDB.SetMaxOpenConns(1) + + require.NoError(t, db.AutoMigrate(&NotificationProvider{}, &WebPushSubscription{})) + + // Same DDL as backend/internal/api/routes/routes.go's post-AutoMigrate + // singleton-index step. + require.NoError(t, db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_webpush_singleton + ON notification_providers(type) WHERE type = 'webpush'`).Error) + + const attempts = 2 + var wg sync.WaitGroup + errs := make([]error, attempts) + wg.Add(attempts) + for i := 0; i < attempts; i++ { + go func(idx int) { + defer wg.Done() + provider := &NotificationProvider{ + Name: "Browser Push", + Type: "webpush", + } + errs[idx] = db.Create(provider).Error + }(i) + } + wg.Wait() + + successCount := 0 + failureCount := 0 + for _, e := range errs { + if e == nil { + successCount++ + } else { + failureCount++ + assert.Contains(t, e.Error(), "UNIQUE constraint failed", "losing insert should fail on the partial unique index, got: %v", e) + } + } + + assert.Equal(t, 1, successCount, "exactly one concurrent insert should succeed") + assert.Equal(t, 1, failureCount, "exactly one concurrent insert should fail with a unique-constraint violation") + + var count int64 + require.NoError(t, db.Model(&NotificationProvider{}).Where("type = ?", "webpush").Count(&count).Error) + assert.Equal(t, int64(1), count, "only one webpush provider row should exist after the race") +} From 23c61f506beac51406bd77eae92e556c6b7cb695 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Tue, 15 Sep 2026 00:00:24 +0000 Subject: [PATCH 04/13] feat: wire webpush into notify provider allowlist Adds "webpush" to isSupportedNotificationProviderType and supportsJSONTemplates (its plaintext payload is JSON, same providers/internal/render convention as every other JSON-template type), a new FlagWebPushServiceEnabled dispatch-enabled flag, the providers/webpush blank import for registry self-registration, and "webpush" in the registry consistency test's supportedTypes slice. Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN --- backend/internal/services/notification_feature_flags.go | 1 + backend/internal/services/notification_service.go | 6 ++++-- .../notification_service_registry_consistency_test.go | 2 +- backend/internal/services/notify_providers_import.go | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/internal/services/notification_feature_flags.go b/backend/internal/services/notification_feature_flags.go index c1b0db08f..e92f74a3c 100644 --- a/backend/internal/services/notification_feature_flags.go +++ b/backend/internal/services/notification_feature_flags.go @@ -17,5 +17,6 @@ const ( FlagSlackServiceEnabled = "feature.notifications.service.slack.enabled" FlagPushoverServiceEnabled = "feature.notifications.service.pushover.enabled" FlagNtfyServiceEnabled = "feature.notifications.service.ntfy.enabled" + FlagWebPushServiceEnabled = "feature.notifications.service.webpush.enabled" FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled" ) diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 5f480c4ef..a6a383c97 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -126,7 +126,7 @@ func validateDiscordProviderURL(providerType, rawURL string) error { // supportsJSONTemplates returns true if the provider type can use JSON templates func supportsJSONTemplates(providerType string) bool { switch strings.ToLower(providerType) { - case "webhook", "discord", "gotify", "slack", "generic", "telegram", "pushover", "ntfy": + case "webhook", "discord", "gotify", "slack", "generic", "telegram", "pushover", "ntfy", "webpush": return true default: return false @@ -135,7 +135,7 @@ func supportsJSONTemplates(providerType string) bool { func isSupportedNotificationProviderType(providerType string) bool { switch strings.ToLower(strings.TrimSpace(providerType)) { - case "discord", "email", "gotify", "webhook", "telegram", "slack", "pushover", "ntfy": + case "discord", "email", "gotify", "webhook", "telegram", "slack", "pushover", "ntfy", "webpush": return true default: return false @@ -160,6 +160,8 @@ func (s *NotificationService) isDispatchEnabled(providerType string) bool { return s.getFeatureFlagValue(FlagPushoverServiceEnabled, true) case "ntfy": return s.getFeatureFlagValue(FlagNtfyServiceEnabled, true) + case "webpush": + return s.getFeatureFlagValue(FlagWebPushServiceEnabled, true) default: return false } diff --git a/backend/internal/services/notification_service_registry_consistency_test.go b/backend/internal/services/notification_service_registry_consistency_test.go index 3ba76d8fd..a9beb3e50 100644 --- a/backend/internal/services/notification_service_registry_consistency_test.go +++ b/backend/internal/services/notification_service_registry_consistency_test.go @@ -32,7 +32,7 @@ func TestSupportedProviderAllowlistIsSubsetOfRegisteredTypes(t *testing.T) { // (notification_service.go) — kept as a literal list here rather than // derived from the function itself, since that switch has no // enumerable form to introspect. - supportedTypes := []string{"discord", "email", "gotify", "webhook", "telegram", "slack", "pushover", "ntfy"} + supportedTypes := []string{"discord", "email", "gotify", "webhook", "telegram", "slack", "pushover", "ntfy", "webpush"} for _, providerType := range supportedTypes { if !isSupportedNotificationProviderType(providerType) { diff --git a/backend/internal/services/notify_providers_import.go b/backend/internal/services/notify_providers_import.go index 2eafbfb05..4a8116cba 100644 --- a/backend/internal/services/notify_providers_import.go +++ b/backend/internal/services/notify_providers_import.go @@ -29,4 +29,5 @@ import ( _ "github.com/Wikid82/go_notify_yourself/providers/slack" _ "github.com/Wikid82/go_notify_yourself/providers/telegram" _ "github.com/Wikid82/go_notify_yourself/providers/webhook" + _ "github.com/Wikid82/go_notify_yourself/providers/webpush" ) From af9188f3016f14fb2296e21bffd00e212b80fac5 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Tue, 15 Sep 2026 00:10:11 +0000 Subject: [PATCH 05/13] feat: add web push dispatch fan-out and subscription pruning Adds dispatchWebPushViaNotify (notify_webpush_adapter.go): fans a single logical notification out to every WebPushSubscription row under a webpush provider, one webpush.Client per row, sequentially within the provider's already-backgrounded dispatch goroutine to bound outbound concurrency. extractHTTPStatusFromNotifyError regex-parses the numeric HTTP status out of transport.Wrapper's plain formatted error string (go_notify_yourself exposes no typed status error) to detect the standard Web Push 404/410 "subscription is gone" signal. A subscription reported gone is pruned immediately; any other failure increments FailureCount and prunes at webpushMaxConsecutiveFailures (10) to bound the table without nuking a subscription on one transient failure. Wires the new "webpush" branch into SendExternal's dispatch loop, parallel to the existing "email" special case. New file is at 100% statement coverage. Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN --- .../internal/services/notification_service.go | 4 + .../services/notify_webpush_adapter.go | 190 +++++++++ .../services/notify_webpush_adapter_test.go | 394 ++++++++++++++++++ 3 files changed, 588 insertions(+) create mode 100644 backend/internal/services/notify_webpush_adapter.go create mode 100644 backend/internal/services/notify_webpush_adapter_test.go diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index a6a383c97..dc3a60079 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -269,6 +269,10 @@ func (s *NotificationService) SendExternal(ctx context.Context, eventType, title go s.dispatchEmailViaNotify(ctx, provider, eventType, title, message) continue } + if strings.ToLower(strings.TrimSpace(provider.Type)) == "webpush" { + go s.dispatchWebPushViaNotify(ctx, provider, eventType, title, message, data) + continue + } go func(p models.NotificationProvider) { if !supportsJSONTemplates(p.Type) { logger.Log().WithField("provider", util.SanitizeForLog(p.Name)).WithField("type", p.Type).Warn("Provider type is not supported by notify-only runtime") diff --git a/backend/internal/services/notify_webpush_adapter.go b/backend/internal/services/notify_webpush_adapter.go new file mode 100644 index 000000000..d0691f398 --- /dev/null +++ b/backend/internal/services/notify_webpush_adapter.go @@ -0,0 +1,190 @@ +package services + +import ( + "context" + "encoding/json" + "regexp" + "strconv" + "strings" + "time" + + notify "github.com/Wikid82/go_notify_yourself" + "github.com/Wikid82/go_notify_yourself/providers/webpush" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/util" +) + +// webpushMaxConsecutiveFailures bounds how many consecutive non-404/410 +// send failures a WebPushSubscription row tolerates before being pruned as +// presumed-dead (docs/plans/current_spec.md §3.5 step 4 / §3.8). A +// subscription survives up to webpushMaxConsecutiveFailures-1 consecutive +// failures; on reaching this count it is deleted, bounding the table from +// accumulating rows that fail forever for non-404/410 reasons (e.g. a push +// service outage that never resolves before it starts returning 410). +const webpushMaxConsecutiveFailures = 10 + +// webpushServiceConfig is the JSON shape stored in +// NotificationProvider.ServiceConfig for a Type="webpush" row (§3.2): +// the VAPID public key and subject, which are safe to expose (unlike +// VAPIDPrivateKey, stored unencrypted in NotificationProvider.Token, +// already json:"-"). +type webpushServiceConfig struct { + VAPIDPublicKey string `json:"vapid_public_key"` + VAPIDSubject string `json:"vapid_subject"` +} + +// providerStatusPattern extracts the numeric HTTP status code out of +// transport.Wrapper.Send's plain formatted error string +// ("provider returned status %d" or "provider returned status %d: %s") — +// see extractHTTPStatusFromNotifyError's doc comment for why this +// regex-based approach is necessary rather than a typed/sentinel error. +var providerStatusPattern = regexp.MustCompile(`provider returned status (\d+)`) + +// extractHTTPStatusFromNotifyError parses the numeric HTTP status code out +// of a notify.Sender.Send error's message, if one is present. +// +// This exists because, per docs/plans/current_spec.md §2.1/§7 risk 1, +// go_notify_yourself's transport.Wrapper.Send returns non-2xx failures as a +// plain formatted string (fmt.Errorf("provider returned status %d[: %s]", +// ...)) with no typed/sentinel error anywhere in transport/ or webpush/ — +// there is no errors.Is-compatible way to detect the standard Web Push +// "subscription is gone" 404/410 signal. Parsing the status out of the +// error text is fragile-by-construction (an upstream wording change +// silently breaks detection), which is why this is isolated to one small, +// independently-tested function: if it ever stops matching, the failure +// mode is "subscriptions never auto-prune on 404/410, FailureCount +// accumulates and the row prunes at webpushMaxConsecutiveFailures instead" +// (safe-ish degradation) rather than a crash or silent data loss. +// +// webpush.Client.Send wraps the transport error further +// ("failed to send web push: %w"), so the match is done as a substring +// search against the full error text, not an anchored prefix. +func extractHTTPStatusFromNotifyError(err error) (status int, ok bool) { + if err == nil { + return 0, false + } + match := providerStatusPattern.FindStringSubmatch(err.Error()) + if len(match) != 2 { + return 0, false + } + parsed, convErr := strconv.Atoi(match[1]) + if convErr != nil { + return 0, false + } + return parsed, true +} + +// dispatchWebPushViaNotify fans a single logical notification out to every +// WebPushSubscription row under provider — one webpush.Client per row, all +// sharing provider's VAPID identity (docs/plans/current_spec.md §3.5). +// +// Sends run sequentially within this already-backgrounded goroutine +// (SendExternal already calls this via `go s.dispatchWebPushViaNotify(...)` +// per provider), not one goroutine per subscription — a provider with many +// subscriptions would otherwise spawn unbounded concurrent outbound HTTP +// requests. This is a deliberate, documented trade-off (§3.5/§7 risk 3), +// not an oversight. +// +// This function never returns an error to its caller (matching the +// fire-and-forget style of every existing dispatchXxxViaNotify); it logs +// and records per-subscription outcomes only. +func (s *NotificationService) dispatchWebPushViaNotify(ctx context.Context, provider models.NotificationProvider, eventType, title, message string, data map[string]any) { + var cfg webpushServiceConfig + if err := json.Unmarshal([]byte(provider.ServiceConfig), &cfg); err != nil { + logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(provider.Name)).Error("Failed to parse Web Push provider service config") + return + } + vapidPrivateKey := strings.TrimSpace(provider.Token) + if strings.TrimSpace(cfg.VAPIDPublicKey) == "" || strings.TrimSpace(cfg.VAPIDSubject) == "" || vapidPrivateKey == "" { + logger.Log().WithField("provider", util.SanitizeForLog(provider.Name)).Error("Web Push provider is missing VAPID identity fields") + return + } + + var subscriptions []models.WebPushSubscription + if err := s.DB.Where("provider_id = ?", provider.ID).Find(&subscriptions).Error; err != nil { + logger.Log().WithError(err).WithField("provider", util.SanitizeForLog(provider.Name)).Error("Failed to load Web Push subscriptions") + return + } + if len(subscriptions) == 0 { + return + } + + tmpl, customTemplate := resolveTemplateFields(provider) + msg := notify.Message{ + Title: title, + Body: message, + EventType: eventType, + Data: notifyMessageDataFromLegacyFlatMap(data), + } + + for _, sub := range subscriptions { + client := webpush.New(webpush.Config{ + VAPIDPublicKey: cfg.VAPIDPublicKey, + VAPIDPrivateKey: vapidPrivateKey, + VAPIDSubject: cfg.VAPIDSubject, + Endpoint: sub.Endpoint, + P256dh: sub.P256dh, + Auth: sub.Auth, + Template: tmpl, + CustomTemplate: customTemplate, + }, s.notifyWrapper) + + sendErr := client.Send(ctx, msg) + s.recordWebPushSendResult(sub, sendErr) + } +} + +// recordWebPushSendResult applies the partial-failure handling policy for +// one subscription's send outcome (docs/plans/current_spec.md §3.5 step 4): +// - success: refresh LastSeenAt, reset FailureCount. +// - 404/410 (subscription is gone, per the standard Web Push convention): +// delete the row immediately. +// - any other failure: increment FailureCount/LastFailureAt; once +// FailureCount reaches webpushMaxConsecutiveFailures, delete the row as +// presumed-dead. +// +// Every failure is logged via the existing logger.Log().WithError(...) +// convention — never silent, matching dispatchViaNotify's existing style. +func (s *NotificationService) recordWebPushSendResult(sub models.WebPushSubscription, sendErr error) { + now := time.Now() + + if sendErr == nil { + if err := s.DB.Model(&models.WebPushSubscription{}).Where("id = ?", sub.ID).Updates(map[string]any{ + "last_seen_at": now, + "failure_count": 0, + }).Error; err != nil { + logger.Log().WithError(err).WithField("subscription_id", sub.ID).Error("Failed to update Web Push subscription after successful send") + } + return + } + + if status, ok := extractHTTPStatusFromNotifyError(sendErr); ok && (status == 404 || status == 410) { + logger.Log().WithError(sendErr).WithField("subscription_id", sub.ID).WithField("status", status). + Info("Pruning Web Push subscription reported gone by the push service (404/410)") + if err := s.DB.Delete(&models.WebPushSubscription{}, "id = ?", sub.ID).Error; err != nil { + logger.Log().WithError(err).WithField("subscription_id", sub.ID).Error("Failed to prune dead Web Push subscription") + } + return + } + + logger.Log().WithError(sendErr).WithField("subscription_id", sub.ID).Error("Failed to send Web Push notification") + + newFailureCount := sub.FailureCount + 1 + if newFailureCount >= webpushMaxConsecutiveFailures { + logger.Log().WithField("subscription_id", sub.ID).WithField("failure_count", newFailureCount). + Warn("Pruning Web Push subscription after reaching max consecutive failures") + if err := s.DB.Delete(&models.WebPushSubscription{}, "id = ?", sub.ID).Error; err != nil { + logger.Log().WithError(err).WithField("subscription_id", sub.ID).Error("Failed to prune presumed-dead Web Push subscription") + } + return + } + + if err := s.DB.Model(&models.WebPushSubscription{}).Where("id = ?", sub.ID).Updates(map[string]any{ + "failure_count": newFailureCount, + "last_failure_at": now, + }).Error; err != nil { + logger.Log().WithError(err).WithField("subscription_id", sub.ID).Error("Failed to update Web Push subscription failure count") + } +} diff --git a/backend/internal/services/notify_webpush_adapter_test.go b/backend/internal/services/notify_webpush_adapter_test.go new file mode 100644 index 000000000..2e11c7712 --- /dev/null +++ b/backend/internal/services/notify_webpush_adapter_test.go @@ -0,0 +1,394 @@ +package services + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/http" + "sync" + "testing" + "time" + + "github.com/Wikid82/go_notify_yourself/providers/webpush" + "github.com/Wikid82/go_notify_yourself/transport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +func setupWebPushAdapterTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.WebPushSubscription{})) + return db +} + +// statusByEndpointRoundTripper is a fake http.RoundTripper that returns a +// configurable status code per request URL (defaulting to 200), so a test +// can drive different subscriptions in the same dispatch to different +// outcomes (success / 404 / 500) without hitting any real network +// destination. It also counts requests per URL for fan-out assertions. +type statusByEndpointRoundTripper struct { + mu sync.Mutex + statusByURL map[string]int + requestCounts map[string]int +} + +func newStatusByEndpointRoundTripper() *statusByEndpointRoundTripper { + return &statusByEndpointRoundTripper{ + statusByURL: map[string]int{}, + requestCounts: map[string]int{}, + } +} + +func (rt *statusByEndpointRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + rt.mu.Lock() + defer rt.mu.Unlock() + url := req.URL.String() + rt.requestCounts[url]++ + status := rt.statusByURL[url] + if status == 0 { + status = http.StatusCreated + } + return &http.Response{ + StatusCode: status, + Body: http.NoBody, + Header: make(http.Header), + }, nil +} + +func (rt *statusByEndpointRoundTripper) count(url string) int { + rt.mu.Lock() + defer rt.mu.Unlock() + return rt.requestCounts[url] +} + +func newWebPushTestWrapper(rt http.RoundTripper) *transport.Wrapper { + return transport.NewWrapper( + transport.WithURLValidator(func(rawURL string, _ bool) (string, error) { return rawURL, nil }), + transport.WithClientFactory(func(bool, int) *http.Client { + return &http.Client{Transport: rt} + }), + transport.WithRetryPolicy(transport.RetryPolicy{MaxAttempts: 1}), + ) +} + +// validSubscriberKeys generates a fresh, valid P256dh/Auth pair so a +// subscription can reach webpush.Client.Send's encryption step without +// error — mirrors go_notify_yourself's own providers/webpush test helper +// (testSubscriber, webpush_test.go), duplicated here since it's unexported. +func validSubscriberKeys(t *testing.T) (p256dh, auth string) { + t.Helper() + receiver, err := ecdh.P256().GenerateKey(rand.Reader) + require.NoError(t, err) + authSecret := make([]byte, 16) + _, err = rand.Read(authSecret) + require.NoError(t, err) + return base64.RawURLEncoding.EncodeToString(receiver.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(authSecret) +} + +func validWebPushProvider(t *testing.T) models.NotificationProvider { + t.Helper() + pub, priv, err := webpush.GenerateVAPIDKeyPair() + require.NoError(t, err) + return models.NotificationProvider{ + ID: "provider-1", + Name: "Browser Push", + Type: "webpush", + Enabled: true, + Token: priv, + ServiceConfig: fmt.Sprintf(`{"vapid_public_key":%q,"vapid_subject":"mailto:ops@example.com"}`, pub), + Template: "minimal", + } +} + +// --- extractHTTPStatusFromNotifyError --- + +func TestExtractHTTPStatusFromNotifyError(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + wantOK bool + }{ + { + name: "nil error", + err: nil, + wantStatus: 0, + wantOK: false, + }, + { + name: "status with hint, unwrapped (transport.Wrapper's own shape)", + err: errors.New("provider returned status 410: subscription has unsubscribed or expired"), + wantStatus: 410, + wantOK: true, + }, + { + name: "status without hint", + err: errors.New("provider returned status 404"), + wantStatus: 404, + wantOK: true, + }, + { + name: "wrapped by webpush.Client.Send's 'failed to send web push' prefix", + err: fmt.Errorf("failed to send web push: %w", errors.New("provider returned status 410: gone")), + wantStatus: 410, + wantOK: true, + }, + { + name: "unrelated error text", + err: errors.New("connection refused"), + wantStatus: 0, + wantOK: false, + }, + { + name: "provider request failed after retries (no status embedded)", + err: errors.New("provider request failed after retries: connection reset"), + wantStatus: 0, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status, ok := extractHTTPStatusFromNotifyError(tt.err) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantStatus, status) + }) + } +} + +// --- dispatchWebPushViaNotify: guard-clause paths --- + +func TestDispatchWebPushViaNotify_MalformedServiceConfigLogsAndReturns(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := models.NotificationProvider{ID: "p1", Type: "webpush", Token: "priv", ServiceConfig: "not-json"} + require.NoError(t, db.Create(&provider).Error) + sub := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u1", Endpoint: "https://push.example.net/a", P256dh: "x", Auth: "y"} + require.NoError(t, db.Create(&sub).Error) + + rt := newStatusByEndpointRoundTripper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(newWebPushTestWrapper(rt))) + + // Must not panic; must not touch the subscription row. + svc.dispatchWebPushViaNotify(context.Background(), provider, "test", "Title", "Message", nil) + + assert.Equal(t, 0, rt.count("https://push.example.net/a"), "no HTTP request should be sent for a malformed service config") +} + +func TestDispatchWebPushViaNotify_MissingVAPIDFieldsLogsAndReturns(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := models.NotificationProvider{ + ID: "p1", Type: "webpush", Token: "", // missing private key + ServiceConfig: `{"vapid_public_key":"BN...","vapid_subject":"mailto:ops@example.com"}`, + } + require.NoError(t, db.Create(&provider).Error) + sub := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u1", Endpoint: "https://push.example.net/b", P256dh: "x", Auth: "y"} + require.NoError(t, db.Create(&sub).Error) + + rt := newStatusByEndpointRoundTripper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(newWebPushTestWrapper(rt))) + + svc.dispatchWebPushViaNotify(context.Background(), provider, "test", "Title", "Message", nil) + + assert.Equal(t, 0, rt.count("https://push.example.net/b")) +} + +func TestDispatchWebPushViaNotify_NoSubscriptionsIsNoop(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := validWebPushProvider(t) + require.NoError(t, db.Create(&provider).Error) + + rt := newStatusByEndpointRoundTripper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(newWebPushTestWrapper(rt))) + + // Should return cleanly with zero subscription rows and no HTTP calls. + svc.dispatchWebPushViaNotify(context.Background(), provider, "test", "Title", "Message", nil) +} + +// --- dispatchWebPushViaNotify: fan-out --- + +func TestDispatchWebPushViaNotify_FansOutToEverySubscription(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := validWebPushProvider(t) + require.NoError(t, db.Create(&provider).Error) + + p256dhA, authA := validSubscriberKeys(t) + p256dhB, authB := validSubscriberKeys(t) + subA := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u1", Endpoint: "https://push.example.net/subA", P256dh: p256dhA, Auth: authA, FailureCount: 3} + subB := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u2", Endpoint: "https://push.example.net/subB", P256dh: p256dhB, Auth: authB} + require.NoError(t, db.Create(&subA).Error) + require.NoError(t, db.Create(&subB).Error) + + rt := newStatusByEndpointRoundTripper() // both default to 201 (success) + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(newWebPushTestWrapper(rt))) + + svc.dispatchWebPushViaNotify(context.Background(), provider, "test", "Title", "Message", nil) + + assert.Equal(t, 1, rt.count(subA.Endpoint)) + assert.Equal(t, 1, rt.count(subB.Endpoint)) + + var reloadedA, reloadedB models.WebPushSubscription + require.NoError(t, db.First(&reloadedA, "id = ?", subA.ID).Error) + require.NoError(t, db.First(&reloadedB, "id = ?", subB.ID).Error) + assert.Equal(t, 0, reloadedA.FailureCount, "successful send resets FailureCount") + assert.False(t, reloadedA.LastSeenAt.IsZero()) + assert.False(t, reloadedB.LastSeenAt.IsZero()) +} + +func TestDispatchWebPushViaNotify_Prunes404And410Immediately(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := validWebPushProvider(t) + require.NoError(t, db.Create(&provider).Error) + + p256dh404, auth404 := validSubscriberKeys(t) + p256dh410, auth410 := validSubscriberKeys(t) + sub404 := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u1", Endpoint: "https://push.example.net/gone404", P256dh: p256dh404, Auth: auth404} + sub410 := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u2", Endpoint: "https://push.example.net/gone410", P256dh: p256dh410, Auth: auth410} + require.NoError(t, db.Create(&sub404).Error) + require.NoError(t, db.Create(&sub410).Error) + + rt := newStatusByEndpointRoundTripper() + rt.statusByURL[sub404.Endpoint] = http.StatusNotFound + rt.statusByURL[sub410.Endpoint] = http.StatusGone + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(newWebPushTestWrapper(rt))) + + svc.dispatchWebPushViaNotify(context.Background(), provider, "test", "Title", "Message", nil) + + var count int64 + require.NoError(t, db.Model(&models.WebPushSubscription{}).Where("provider_id = ?", provider.ID).Count(&count).Error) + assert.Equal(t, int64(0), count, "both 404 and 410 subscriptions should be pruned") +} + +func TestDispatchWebPushViaNotify_NonGoneFailureIncrementsFailureCountBelowThreshold(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := validWebPushProvider(t) + require.NoError(t, db.Create(&provider).Error) + + p256dh, auth := validSubscriberKeys(t) + sub := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u1", Endpoint: "https://push.example.net/flaky", P256dh: p256dh, Auth: auth, FailureCount: 3} + require.NoError(t, db.Create(&sub).Error) + + rt := newStatusByEndpointRoundTripper() + rt.statusByURL[sub.Endpoint] = http.StatusInternalServerError + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(newWebPushTestWrapper(rt))) + + svc.dispatchWebPushViaNotify(context.Background(), provider, "test", "Title", "Message", nil) + + var reloaded models.WebPushSubscription + require.NoError(t, db.First(&reloaded, "id = ?", sub.ID).Error) + assert.Equal(t, 4, reloaded.FailureCount) + require.NotNil(t, reloaded.LastFailureAt) +} + +func TestDispatchWebPushViaNotify_PrunesAtMaxConsecutiveFailures(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := validWebPushProvider(t) + require.NoError(t, db.Create(&provider).Error) + + p256dh, auth := validSubscriberKeys(t) + sub := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u1", Endpoint: "https://push.example.net/dying", P256dh: p256dh, Auth: auth, FailureCount: webpushMaxConsecutiveFailures - 1} + require.NoError(t, db.Create(&sub).Error) + + rt := newStatusByEndpointRoundTripper() + rt.statusByURL[sub.Endpoint] = http.StatusInternalServerError + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(newWebPushTestWrapper(rt))) + + svc.dispatchWebPushViaNotify(context.Background(), provider, "test", "Title", "Message", nil) + + var count int64 + require.NoError(t, db.Model(&models.WebPushSubscription{}).Where("id = ?", sub.ID).Count(&count).Error) + assert.Equal(t, int64(0), count, "subscription should be pruned once FailureCount reaches the max threshold") +} + +// --- recordWebPushSendResult: direct unit coverage of the branch logic --- + +func TestRecordWebPushSendResult_SuccessResetsFailureCount(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := models.NotificationProvider{ID: "p1", Type: "webpush"} + require.NoError(t, db.Create(&provider).Error) + sub := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u1", Endpoint: "https://push.example.net/ok", P256dh: "x", Auth: "y", FailureCount: 5} + require.NoError(t, db.Create(&sub).Error) + + svc := NewNotificationService(db, nil) + svc.recordWebPushSendResult(sub, nil) + + var reloaded models.WebPushSubscription + require.NoError(t, db.First(&reloaded, "id = ?", sub.ID).Error) + assert.Equal(t, 0, reloaded.FailureCount) + assert.WithinDuration(t, time.Now(), reloaded.LastSeenAt, 5*time.Second) +} + +func TestExtractHTTPStatusFromNotifyError_UnparsableNumberIsNotOK(t *testing.T) { + // A status string wide enough to overflow int (still matches \d+, but + // strconv.Atoi fails) exercises the defensive fallback branch. + status, ok := extractHTTPStatusFromNotifyError(errors.New("provider returned status 99999999999999999999")) + assert.False(t, ok) + assert.Equal(t, 0, status) +} + +func TestDispatchWebPushViaNotify_SubscriptionLoadErrorLogsAndReturns(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := validWebPushProvider(t) + require.NoError(t, db.Create(&provider).Error) + + // Drop the table out from under the query to force a DB error on the + // subscriptions Find call, without panicking the dispatch path. + require.NoError(t, db.Migrator().DropTable(&models.WebPushSubscription{})) + + rt := newStatusByEndpointRoundTripper() + svc := NewNotificationService(db, nil, WithNotifyTransportWrapper(newWebPushTestWrapper(rt))) + + svc.dispatchWebPushViaNotify(context.Background(), provider, "test", "Title", "Message", nil) +} + +func TestRecordWebPushSendResult_DBErrorsAreLoggedNotPanicked(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := models.NotificationProvider{ID: "p1", Type: "webpush"} + require.NoError(t, db.Create(&provider).Error) + sub := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u1", Endpoint: "https://push.example.net/closed", P256dh: "x", Auth: "y", FailureCount: webpushMaxConsecutiveFailures - 1} + require.NoError(t, db.Create(&sub).Error) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + svc := NewNotificationService(db, nil) + + // Each branch's DB write now fails (connection closed) — all must be + // logged, never panicked. + assert.NotPanics(t, func() { svc.recordWebPushSendResult(sub, nil) }) + assert.NotPanics(t, func() { + svc.recordWebPushSendResult(sub, errors.New("provider returned status 410: gone")) + }) + assert.NotPanics(t, func() { + svc.recordWebPushSendResult(sub, errors.New("provider returned status 500")) + }) + lowFailureSub := sub + lowFailureSub.FailureCount = 0 + assert.NotPanics(t, func() { + svc.recordWebPushSendResult(lowFailureSub, errors.New("provider returned status 500")) + }) +} + +func TestRecordWebPushSendResult_AmbiguousFailureNoStatusStillCountsAsFailure(t *testing.T) { + db := setupWebPushAdapterTestDB(t) + provider := models.NotificationProvider{ID: "p1", Type: "webpush"} + require.NoError(t, db.Create(&provider).Error) + sub := models.WebPushSubscription{ProviderID: provider.ID, UserID: "u1", Endpoint: "https://push.example.net/ambiguous", P256dh: "x", Auth: "y"} + require.NoError(t, db.Create(&sub).Error) + + svc := NewNotificationService(db, nil) + svc.recordWebPushSendResult(sub, errors.New("outbound request failed: connection refused")) + + var reloaded models.WebPushSubscription + require.NoError(t, db.First(&reloaded, "id = ?", sub.ID).Error) + assert.Equal(t, 1, reloaded.FailureCount) +} From 1570aa7e543f70c6bed452d14f1839e9d2e2664d Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Tue, 15 Sep 2026 00:25:44 +0000 Subject: [PATCH 06/13] feat: add web push provisioning and subscription API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds WebPushHandler (Provision, VAPIDPublicKey, Subscribe, ListSubscriptions, Unsubscribe) and the matching NotificationService methods (ProvisionWebPush, GetWebPushVAPIDPublicKey, RegisterWebPushSubscription, ListWebPushSubscriptionsForUser, DeleteWebPushSubscription), wired under the management route group. Provisioning is admin-only (RequireRole(admin), mirroring the Test/Preview precedent); VAPID key read, subscribe, list, and unsubscribe are self-service for any management-access user (§3.4.0). ProvisionWebPush's cheap COUNT fast-path is backstopped by the idx_webpush_singleton partial unique index added in a prior commit; a losing CreateProvider INSERT is caught and mapped to 409, never a raw 500, using this codebase's existing errors.Is(gorm.ErrDuplicatedKey)/"UNIQUE constraint failed" detection idiom. Also fixes a token-wiping bug the new type surfaced: CreateProvider and UpdateProvider's token-preserving allowlists (and the generic Update handler's provider-type allowlist) didn't include "webpush", which would have silently erased the VAPID private key on create/ update. Not in the original commit-6 file list, but required for ProvisionWebPush and any admin edit of a webpush provider's preferences to work at all. Includes the two required regression tests: concurrent Provision requests never surface a raw 500 (only one 201, the loser 409), and a RoleUser caller gets 403 from the existing generic PUT /notifications/providers/:id when setting a NotifySecurityXxx field on a webpush row (pins already-existing admin-gate behavior, no new authz code). Also updates TestManagementGroup_MutationsAreAdminGuarded's userOKMutationAllowlist for the two intentionally self-service webpush routes. Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN --- .../handlers/notification_provider_handler.go | 4 +- .../internal/api/handlers/webpush_handler.go | 198 +++++++++ .../api/handlers/webpush_handler_test.go | 375 ++++++++++++++++++ backend/internal/api/routes/routes.go | 12 + backend/internal/api/routes/routes_test.go | 22 +- .../internal/services/notification_service.go | 4 +- .../services/webpush_provider_service.go | 269 +++++++++++++ 7 files changed, 870 insertions(+), 14 deletions(-) create mode 100644 backend/internal/api/handlers/webpush_handler.go create mode 100644 backend/internal/api/handlers/webpush_handler_test.go create mode 100644 backend/internal/services/webpush_provider_service.go diff --git a/backend/internal/api/handlers/notification_provider_handler.go b/backend/internal/api/handlers/notification_provider_handler.go index 8b16cf2f7..656cf9fcc 100644 --- a/backend/internal/api/handlers/notification_provider_handler.go +++ b/backend/internal/api/handlers/notification_provider_handler.go @@ -242,12 +242,12 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) { } providerType := strings.ToLower(strings.TrimSpace(existing.Type)) - if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" && providerType != "ntfy" { + if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" && providerType != "ntfy" && providerType != "webpush" { respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type") return } - if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy") && strings.TrimSpace(req.Token) == "" { + if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy" || providerType == "webpush") && strings.TrimSpace(req.Token) == "" { // Keep existing token if update payload omits token req.Token = existing.Token } diff --git a/backend/internal/api/handlers/webpush_handler.go b/backend/internal/api/handlers/webpush_handler.go new file mode 100644 index 000000000..9f2a5ae2c --- /dev/null +++ b/backend/internal/api/handlers/webpush_handler.go @@ -0,0 +1,198 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "github.com/Wikid82/charon/backend/internal/services" +) + +// WebPushHandler exposes the Web Push provisioning and subscription +// lifecycle endpoints (docs/plans/current_spec.md §3.4). All routes are +// mounted on the existing authenticated `management` route group; the +// Provision route is additionally gated admin-only at the route +// registration layer (routes.go), mirroring the Test/Preview precedent — +// see §3.4.0 for the full authorization-model writeup. +type WebPushHandler struct { + service *services.NotificationService +} + +func NewWebPushHandler(service *services.NotificationService) *WebPushHandler { + return &WebPushHandler{service: service} +} + +type webPushProvisionRequest struct { + Name string `json:"name"` + VAPIDSubject string `json:"vapid_subject"` +} + +type webPushSubscribeRequest struct { + Endpoint string `json:"endpoint"` + Keys struct { + P256dh string `json:"p256dh"` + Auth string `json:"auth"` + } `json:"keys"` + UserAgent string `json:"user_agent"` +} + +type webPushSubscriptionResponse struct { + ID string `json:"id"` + Endpoint string `json:"endpoint"` + UserAgent string `json:"user_agent,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +// webPushUserID extracts the authenticated caller's user ID (set by +// AuthMiddleware as a uint, per models.User.ID) and renders it as the +// string WebPushSubscription.UserID/models column expects. On failure it +// writes a 401 itself, matching requireUserID's contract. +func webPushUserID(c *gin.Context) (string, bool) { + userID, ok := requireUserID(c) + if !ok { + return "", false + } + return strconv.FormatUint(uint64(userID), 10), true +} + +// Provision generates a new VAPID identity and creates the singleton +// webpush NotificationProvider row (§3.4.1). Admin-only — enforced by +// middleware.RequireRole(models.RoleAdmin) at the route registration layer +// (routes.go), matching the Test/Preview admin-only precedent on this same +// route group; no additional in-handler check is needed. +func (h *WebPushHandler) Provision(c *gin.Context) { + var req webPushProvisionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) + return + } + + provider, err := h.service.ProvisionWebPush(req.Name, req.VAPIDSubject) + if err != nil { + switch { + case errors.Is(err, services.ErrWebPushAlreadyProvisioned): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrWebPushInvalidRequest): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to provision web push provider"}) + } + return + } + + provider.HasToken = provider.Token != "" + provider.Token = "" + c.JSON(http.StatusCreated, provider) +} + +// VAPIDPublicKey serves the current VAPID public key for +// PushManager.subscribe (§3.4.2). Available to any authenticated +// management-access user, not just admins — a non-admin user's browser can +// still subscribe to receive alerts. +func (h *WebPushHandler) VAPIDPublicKey(c *gin.Context) { + key, err := h.service.GetWebPushVAPIDPublicKey() + if err != nil { + if errors.Is(err, services.ErrWebPushNotProvisioned) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load web push VAPID public key"}) + return + } + c.JSON(http.StatusOK, gin.H{"vapid_public_key": key}) +} + +// Subscribe registers (or idempotently re-registers) the caller's browser +// PushSubscription (§3.4.3). +func (h *WebPushHandler) Subscribe(c *gin.Context) { + userID, ok := webPushUserID(c) + if !ok { + return + } + + var req webPushSubscribeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) + return + } + + sub, created, err := h.service.RegisterWebPushSubscription(userID, services.WebPushSubscribeInput{ + Endpoint: req.Endpoint, + P256dh: req.Keys.P256dh, + Auth: req.Keys.Auth, + UserAgent: req.UserAgent, + }) + if err != nil { + switch { + case errors.Is(err, services.ErrWebPushNotProvisioned): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, services.ErrWebPushProviderDisabled): + // Exact text pinned by docs/plans/current_spec.md §3.4.3, kept + // independent of the sentinel error's own (lowercase, Go-idiom) + // message. + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Web Push provider is disabled"}) + case errors.Is(err, services.ErrWebPushInvalidRequest): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register web push subscription"}) + } + return + } + + status := http.StatusOK + if created { + status = http.StatusCreated + } + c.JSON(status, gin.H{"id": sub.ID, "endpoint": sub.Endpoint}) +} + +// ListSubscriptions returns the caller's own subscriptions only (§3.4.4). +func (h *WebPushHandler) ListSubscriptions(c *gin.Context) { + userID, ok := webPushUserID(c) + if !ok { + return + } + + subs, err := h.service.ListWebPushSubscriptionsForUser(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list web push subscriptions"}) + return + } + + resp := make([]webPushSubscriptionResponse, 0, len(subs)) + for _, sub := range subs { + resp = append(resp, webPushSubscriptionResponse{ + ID: sub.ID, + Endpoint: sub.Endpoint, + UserAgent: sub.UserAgent, + CreatedAt: sub.CreatedAt, + LastSeenAt: sub.LastSeenAt, + }) + } + c.JSON(http.StatusOK, resp) +} + +// Unsubscribe removes a subscription owned by the caller (§3.4.5). Returns +// 404 — never 403 — for a subscription that doesn't exist or isn't owned by +// the caller, to avoid confirming existence of another user's row. +func (h *WebPushHandler) Unsubscribe(c *gin.Context) { + userID, ok := webPushUserID(c) + if !ok { + return + } + + id := c.Param("id") + if err := h.service.DeleteWebPushSubscription(userID, id); err != nil { + if errors.Is(err, services.ErrWebPushSubscriptionNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete web push subscription"}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/api/handlers/webpush_handler_test.go b/backend/internal/api/handlers/webpush_handler_test.go new file mode 100644 index 000000000..855192463 --- /dev/null +++ b/backend/internal/api/handlers/webpush_handler_test.go @@ -0,0 +1,375 @@ +package handlers_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/api/handlers" + "github.com/Wikid82/charon/backend/internal/api/middleware" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" +) + +// setupWebPushHandlerTest wires WebPushHandler's routes (and, for the +// admin-gate regression test, the existing generic provider Update route) +// under the same middleware.RequireManagementAccess()/RequireRole(admin) +// gates routes.go registers them with, so these tests exercise the real +// authorization wiring rather than a re-implemented stand-in. Role/user ID +// per request are driven by the X-Test-Role/X-Test-UserID headers so a +// single router serves both admin and non-admin test cases. +func setupWebPushHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + db := handlers.OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.WebPushSubscription{}, &models.Notification{})) + // Mirrors routes.go's post-AutoMigrate singleton-index step exactly + // (docs/plans/current_spec.md §3.3.4). + require.NoError(t, db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_webpush_singleton + ON notification_providers(type) WHERE type = 'webpush'`).Error) + + service := services.NewNotificationService(db, nil) + webPushHandler := handlers.NewWebPushHandler(service) + providerHandler := handlers.NewNotificationProviderHandler(service) + + r := gin.New() + r.Use(func(c *gin.Context) { + role := c.GetHeader("X-Test-Role") + if role == "" { + role = "admin" + } + uid := uint64(1) + if raw := c.GetHeader("X-Test-UserID"); raw != "" { + uid, _ = strconv.ParseUint(raw, 10, 64) + } + c.Set("role", role) + c.Set("userID", uint(uid)) + c.Next() + }) + + api := r.Group("/api/v1") + management := api.Group("/") + management.Use(middleware.RequireManagementAccess()) + management.POST("/notifications/providers/webpush/provision", middleware.RequireRole(models.RoleAdmin), webPushHandler.Provision) + management.GET("/notifications/providers/webpush/vapid-public-key", webPushHandler.VAPIDPublicKey) + management.POST("/notifications/providers/webpush/subscriptions", webPushHandler.Subscribe) + management.GET("/notifications/providers/webpush/subscriptions", webPushHandler.ListSubscriptions) + management.DELETE("/notifications/providers/webpush/subscriptions/:id", webPushHandler.Unsubscribe) + management.PUT("/notifications/providers/:id", providerHandler.Update) + + return r, db +} + +func doJSONRequest(t *testing.T, r *gin.Engine, method, path string, body any, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if body != nil { + require.NoError(t, json.NewEncoder(&buf).Encode(body)) + } + req, err := http.NewRequest(method, path, &buf) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +// --- Provision --- + +func TestWebPushHandler_Provision_Success(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", + map[string]string{"name": "Browser Push", "vapid_subject": "mailto:ops@example.com"}, nil) + + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "webpush", resp["type"]) + assert.NotContains(t, w.Body.String(), `"token"`, "VAPID private key must never be serialized") +} + +func TestWebPushHandler_Provision_InvalidVAPIDSubjectReturns400(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", + map[string]string{"name": "Browser Push", "vapid_subject": "not-a-valid-subject"}, nil) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestWebPushHandler_Provision_SecondAttemptReturns409(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + + first := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", + map[string]string{"name": "Browser Push", "vapid_subject": "mailto:ops@example.com"}, nil) + require.Equal(t, http.StatusCreated, first.Code) + + second := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", + map[string]string{"name": "Browser Push 2", "vapid_subject": "mailto:ops2@example.com"}, nil) + assert.Equal(t, http.StatusConflict, second.Code) +} + +func TestWebPushHandler_Provision_NonAdminReturns403(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", + map[string]string{"name": "Browser Push", "vapid_subject": "mailto:ops@example.com"}, + map[string]string{"X-Test-Role": "user"}) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +// TestWebPushHandler_Provision_ConcurrentRequestsNeverReturn500 is the +// required regression test for docs/plans/current_spec.md §3.1/§3.3.4/§9 +// commit 6: two Provision requests racing the idx_webpush_singleton +// partial unique index must produce exactly one success and the loser must +// see 409 — never a raw 500 — proving CreateProvider's constraint-violation +// error is caught and mapped, not left to surface as an unhandled error. +func TestWebPushHandler_Provision_ConcurrentRequestsNeverReturn500(t *testing.T) { + r, db := setupWebPushHandlerTest(t) + + const attempts = 2 + codes := make([]int, attempts) + var wg sync.WaitGroup + wg.Add(attempts) + for i := 0; i < attempts; i++ { + go func(idx int) { + defer wg.Done() + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", + map[string]string{"name": "Browser Push", "vapid_subject": "mailto:ops@example.com"}, nil) + codes[idx] = w.Code + }(i) + } + wg.Wait() + + successCount, conflictCount, otherCount := 0, 0, 0 + for _, code := range codes { + switch code { + case http.StatusCreated: + successCount++ + case http.StatusConflict: + conflictCount++ + default: + otherCount++ + } + } + + assert.Equal(t, 1, successCount, "exactly one concurrent provision request should succeed") + assert.Equal(t, 1, conflictCount, "the losing request should get 409, not any other status") + assert.Equal(t, 0, otherCount, "no request should ever surface a raw 500 or other status") + + var providerCount int64 + require.NoError(t, db.Model(&models.NotificationProvider{}).Where("type = ?", "webpush").Count(&providerCount).Error) + assert.Equal(t, int64(1), providerCount, "only one webpush provider row should exist after the race") +} + +// --- VAPIDPublicKey --- + +func TestWebPushHandler_VAPIDPublicKey_NotProvisionedReturns404(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + + w := doJSONRequest(t, r, http.MethodGet, "/api/v1/notifications/providers/webpush/vapid-public-key", nil, nil) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestWebPushHandler_VAPIDPublicKey_ReturnsKeyAfterProvisioning(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + + provisionResp := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", + map[string]string{"name": "Browser Push", "vapid_subject": "mailto:ops@example.com"}, nil) + require.Equal(t, http.StatusCreated, provisionResp.Code) + + w := doJSONRequest(t, r, http.MethodGet, "/api/v1/notifications/providers/webpush/vapid-public-key", nil, nil) + require.Equal(t, http.StatusOK, w.Code) + + var resp map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp["vapid_public_key"]) +} + +func TestWebPushHandler_VAPIDPublicKey_DisabledProviderReturns404(t *testing.T) { + r, db := setupWebPushHandlerTest(t) + + provisionResp := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", + map[string]string{"name": "Browser Push", "vapid_subject": "mailto:ops@example.com"}, nil) + require.Equal(t, http.StatusCreated, provisionResp.Code) + + require.NoError(t, db.Model(&models.NotificationProvider{}).Where("type = ?", "webpush").Update("enabled", false).Error) + + w := doJSONRequest(t, r, http.MethodGet, "/api/v1/notifications/providers/webpush/vapid-public-key", nil, nil) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// --- Subscribe / ListSubscriptions / Unsubscribe --- + +func provisionWebPush(t *testing.T, r *gin.Engine) { + t.Helper() + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", + map[string]string{"name": "Browser Push", "vapid_subject": "mailto:ops@example.com"}, nil) + require.Equal(t, http.StatusCreated, w.Code) +} + +func subscribeBody(endpoint string) map[string]any { + return map[string]any{ + "endpoint": endpoint, + "keys": map[string]string{ + "p256dh": "p256dh-value", + "auth": "auth-value", + }, + "user_agent": "Mozilla/5.0 test-agent", + } +} + +func TestWebPushHandler_Subscribe_NotProvisionedReturns404(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("https://push.example.net/a"), nil) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestWebPushHandler_Subscribe_DisabledProviderReturns503(t *testing.T) { + r, db := setupWebPushHandlerTest(t) + provisionWebPush(t, r) + require.NoError(t, db.Model(&models.NotificationProvider{}).Where("type = ?", "webpush").Update("enabled", false).Error) + + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("https://push.example.net/a"), nil) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestWebPushHandler_Subscribe_InvalidEndpointReturns400(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + provisionWebPush(t, r) + + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("http://not-https.example.net/a"), nil) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestWebPushHandler_Subscribe_CreatesThenIdempotentlyReRegisters(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + provisionWebPush(t, r) + + first := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("https://push.example.net/dup"), nil) + require.Equal(t, http.StatusCreated, first.Code) + + second := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("https://push.example.net/dup"), nil) + assert.Equal(t, http.StatusOK, second.Code, "re-registering the same endpoint is idempotent, not a new row") + + var firstResp, secondResp map[string]string + require.NoError(t, json.Unmarshal(first.Body.Bytes(), &firstResp)) + require.NoError(t, json.Unmarshal(second.Body.Bytes(), &secondResp)) + assert.Equal(t, firstResp["id"], secondResp["id"]) +} + +func TestWebPushHandler_ListSubscriptions_ScopedToCaller(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + provisionWebPush(t, r) + + subResp := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("https://push.example.net/mine"), map[string]string{"X-Test-UserID": "1"}) + require.Equal(t, http.StatusCreated, subResp.Code) + + otherResp := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("https://push.example.net/other"), map[string]string{"X-Test-UserID": "2"}) + require.Equal(t, http.StatusCreated, otherResp.Code) + + listResp := doJSONRequest(t, r, http.MethodGet, "/api/v1/notifications/providers/webpush/subscriptions", nil, map[string]string{"X-Test-UserID": "1"}) + require.Equal(t, http.StatusOK, listResp.Code) + + var list []map[string]any + require.NoError(t, json.Unmarshal(listResp.Body.Bytes(), &list)) + require.Len(t, list, 1) + assert.Equal(t, "https://push.example.net/mine", list[0]["endpoint"]) +} + +func TestWebPushHandler_Unsubscribe_OwnSubscriptionSucceeds(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + provisionWebPush(t, r) + + subResp := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("https://push.example.net/mine"), map[string]string{"X-Test-UserID": "1"}) + require.Equal(t, http.StatusCreated, subResp.Code) + var sub map[string]string + require.NoError(t, json.Unmarshal(subResp.Body.Bytes(), &sub)) + + delResp := doJSONRequest(t, r, http.MethodDelete, "/api/v1/notifications/providers/webpush/subscriptions/"+sub["id"], nil, map[string]string{"X-Test-UserID": "1"}) + assert.Equal(t, http.StatusNoContent, delResp.Code) +} + +func TestWebPushHandler_Unsubscribe_ForeignSubscriptionReturns404(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + provisionWebPush(t, r) + + subResp := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("https://push.example.net/mine"), map[string]string{"X-Test-UserID": "1"}) + require.Equal(t, http.StatusCreated, subResp.Code) + var sub map[string]string + require.NoError(t, json.Unmarshal(subResp.Body.Bytes(), &sub)) + + // A different caller (userID 2) must not be able to delete userID 1's + // subscription — and must get 404, not 403, to avoid confirming + // existence of another user's row (§3.4.5). + delResp := doJSONRequest(t, r, http.MethodDelete, "/api/v1/notifications/providers/webpush/subscriptions/"+sub["id"], nil, map[string]string{"X-Test-UserID": "2"}) + assert.Equal(t, http.StatusNotFound, delResp.Code) +} + +func TestWebPushHandler_Unsubscribe_NonexistentReturns404(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + + delResp := doJSONRequest(t, r, http.MethodDelete, "/api/v1/notifications/providers/webpush/subscriptions/does-not-exist", nil, nil) + assert.Equal(t, http.StatusNotFound, delResp.Code) +} + +// TestNotificationProviderUpdate_RoleUserForbiddenFromSecurityToggleOnWebPushProvider +// is the required regression test from docs/plans/current_spec.md §3.4.0 / +// §9 commit 6: it pins the CURRENT behavior that the generic +// PUT /notifications/providers/:id endpoint's unconditional requireAdmin(c) +// gate (notification_provider_handler.go's Update, first line) already +// blocks a RoleUser caller from setting any NotifySecurityXxx field on a +// webpush provider row before the request body is even inspected — closing +// the exfiltration scenario Supervisor flagged (a self-service-subscribed +// low-privileged device flipping on security-event forwarding to itself) +// without any new webpush-specific authorization code. This test exists so +// a future refactor of Update's auth check cannot silently reopen that gap. +func TestNotificationProviderUpdate_RoleUserForbiddenFromSecurityToggleOnWebPushProvider(t *testing.T) { + r, db := setupWebPushHandlerTest(t) + provisionWebPush(t, r) + + var provider models.NotificationProvider + require.NoError(t, db.Where("type = ?", "webpush").First(&provider).Error) + + body := map[string]any{ + "name": provider.Name, + "type": "webpush", + "enabled": true, + "notify_security_waf_blocks": true, + "notify_security_acl_denies": true, + } + w := doJSONRequest(t, r, http.MethodPut, "/api/v1/notifications/providers/"+provider.ID, body, + map[string]string{"X-Test-Role": "user"}) + + assert.Equal(t, http.StatusForbidden, w.Code) + + // Belt-and-suspenders: confirm the toggle was in fact NOT persisted. + var reloaded models.NotificationProvider + require.NoError(t, db.Where("id = ?", provider.ID).First(&reloaded).Error) + assert.False(t, reloaded.NotifySecurityWAFBlocks) + assert.False(t, reloaded.NotifySecurityACLDenies) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 80a6a33e9..520609511 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -695,6 +695,18 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg management.POST("/notifications/providers/preview", middleware.RequireRole(models.RoleAdmin), notificationProviderHandler.Preview) management.GET("/notifications/templates", notificationProviderHandler.Templates) + // Web Push provisioning + subscription lifecycle (docs/plans/current_spec.md §3.4). + // Provisioning creates the shared VAPID identity, so it is admin-only, + // mirroring Test/Preview above; the VAPID public key read and the + // subscribe/list/unsubscribe routes are self-service for any + // authenticated management-access user (§3.4.0). + webPushHandler := handlers.NewWebPushHandler(notificationService) + management.POST("/notifications/providers/webpush/provision", middleware.RequireRole(models.RoleAdmin), webPushHandler.Provision) + management.GET("/notifications/providers/webpush/vapid-public-key", webPushHandler.VAPIDPublicKey) + management.POST("/notifications/providers/webpush/subscriptions", webPushHandler.Subscribe) + management.GET("/notifications/providers/webpush/subscriptions", webPushHandler.ListSubscriptions) + management.DELETE("/notifications/providers/webpush/subscriptions/:id", webPushHandler.Unsubscribe) + // External notification templates (saved templates for providers) notificationTemplateHandler := handlers.NewNotificationTemplateHandlerWithDeps(notificationService, securityService, dataRoot) management.GET("/notifications/external-templates", notificationTemplateHandler.List) diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index 816ab0f15..887f236c0 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -1633,16 +1633,18 @@ var publicMutationAllowlistEnforcement = map[string]string{ // role-based. var userOKMutationAllowlist = map[string]string{ // Per-user self-service (the acting user's own session / profile). - "POST /api/v1/auth/logout": "ends the caller's own session", - "POST /api/v1/auth/refresh": "refreshes the caller's own session", - "POST /api/v1/auth/change-password": "caller changes their own password", - "POST /api/v1/user/profile": "caller updates their own profile", - "POST /api/v1/user/api-key": "caller regenerates their own API key", - "POST /api/v1/changelog/ack": "caller acknowledges the changelog for themselves", - "POST /api/v1/changelog/opt-in": "caller sets their own changelog opt-in", - "PUT /api/v1/users/:id": "UpdateUser has a deliberate self-service branch (own name/password); admin-only fields are rejected in-handler", - "POST /api/v1/notifications/:id/read": "per-user inbox: mark one of the caller's notifications read", - "POST /api/v1/notifications/read-all": "per-user inbox: mark all of the caller's notifications read", + "POST /api/v1/auth/logout": "ends the caller's own session", + "POST /api/v1/auth/refresh": "refreshes the caller's own session", + "POST /api/v1/auth/change-password": "caller changes their own password", + "POST /api/v1/user/profile": "caller updates their own profile", + "POST /api/v1/user/api-key": "caller regenerates their own API key", + "POST /api/v1/changelog/ack": "caller acknowledges the changelog for themselves", + "POST /api/v1/changelog/opt-in": "caller sets their own changelog opt-in", + "PUT /api/v1/users/:id": "UpdateUser has a deliberate self-service branch (own name/password); admin-only fields are rejected in-handler", + "POST /api/v1/notifications/:id/read": "per-user inbox: mark one of the caller's notifications read", + "POST /api/v1/notifications/read-all": "per-user inbox: mark all of the caller's notifications read", + "POST /api/v1/notifications/providers/webpush/subscriptions": "self-service: any management-access user's own browser may subscribe to receive alerts (docs/plans/current_spec.md §3.4.0); provisioning the shared VAPID identity is the separate admin-only /provision route", + "DELETE /api/v1/notifications/providers/webpush/subscriptions/:id": "self-service: caller may unsubscribe their own device; ownership is enforced in-handler (404 for a foreign ID, docs/plans/current_spec.md §3.4.0/§3.4.5)", // Core role=user capability — object-level authz (PermittedHosts / forward-auth), not role. "POST /api/v1/proxy-hosts": "core role=user capability (per-host authz)", diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index dc3a60079..5664168c8 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -590,7 +590,7 @@ func (s *NotificationService) CreateProvider(provider *models.NotificationProvid } } - if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" && provider.Type != "ntfy" && provider.Type != "pushover" { + if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" && provider.Type != "ntfy" && provider.Type != "pushover" && provider.Type != "webpush" { provider.Token = "" } @@ -630,7 +630,7 @@ func (s *NotificationService) UpdateProvider(provider *models.NotificationProvid return err } - if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" || provider.Type == "ntfy" || provider.Type == "pushover" { + if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" || provider.Type == "ntfy" || provider.Type == "pushover" || provider.Type == "webpush" { if strings.TrimSpace(provider.Token) == "" { provider.Token = existing.Token } diff --git a/backend/internal/services/webpush_provider_service.go b/backend/internal/services/webpush_provider_service.go new file mode 100644 index 000000000..2f3d01493 --- /dev/null +++ b/backend/internal/services/webpush_provider_service.go @@ -0,0 +1,269 @@ +package services + +import ( + "encoding/json" + "errors" + "fmt" + neturl "net/url" + "strings" + "time" + + "github.com/Wikid82/go_notify_yourself/providers/webpush" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +// Sentinel errors surfaced by the Web Push provisioning/subscription +// service methods below (docs/plans/current_spec.md §3.4/§3.8). Handlers +// map these to specific HTTP status codes via errors.Is rather than +// string-matching, mirroring this codebase's existing sentinel-error +// idiom (e.g. crowdsec_whitelist_service.go's ErrDuplicateEntry). +var ( + // ErrWebPushInvalidRequest wraps a caller-input validation failure + // (missing/malformed field) — maps to 400. + ErrWebPushInvalidRequest = errors.New("invalid web push request") + + // ErrWebPushAlreadyProvisioned is returned both when the cheap + // service-layer COUNT fast-path finds an existing webpush provider row + // and when CreateProvider's INSERT loses the race at the + // idx_webpush_singleton partial unique index (§3.1/§3.3.4) — the + // caller cannot distinguish the two, and does not need to. Maps to 409. + ErrWebPushAlreadyProvisioned = errors.New("a Web Push provider is already configured") + + // ErrWebPushNotProvisioned means no webpush NotificationProvider row + // exists yet (or, for GetWebPushVAPIDPublicKey, none is Enabled). + // Maps to 404. Lowercase per Go error-string convention (ST1005); the + // exact user-facing 503 text docs/plans/current_spec.md §3.4.3 pins is + // produced by the handler, not by relying on this error's text. + ErrWebPushNotProvisioned = errors.New("web push is not configured") + + // ErrWebPushProviderDisabled means a webpush provider row exists but + // Enabled is false — subscribing while disabled would create + // dead-on-arrival subscriptions. Maps to 503. + ErrWebPushProviderDisabled = errors.New("web push provider is disabled") + + // ErrWebPushSubscriptionNotFound covers both "no such row" and "row + // exists but isn't owned by the caller" — collapsed into one 404 so a + // foreign subscription ID doesn't confirm its own existence, matching + // this codebase's existing respondSanitizedProviderError convention. + ErrWebPushSubscriptionNotFound = errors.New("web push subscription not found") +) + +// ProvisionWebPush generates a new app-wide VAPID identity and creates the +// singleton Type="webpush" NotificationProvider row (docs/plans/current_spec.md +// §3.2/§3.4.1). name and vapidSubject come directly from the admin's +// request; vapidSubject must be "mailto:" or "https://" prefixed, matching +// webpush.Client.Send's own runtime check (validating it here too avoids a +// provision-succeeds-but-every-send-fails footgun). +// +// Race-safety: the cheap COUNT-based check below is a fast-path only, not +// the enforcement mechanism — see CreateProvider and §3.1 "Enforcement" for +// why the idx_webpush_singleton partial unique index (§3.3.4) is what +// actually guarantees the singleton invariant under concurrent requests, +// and how a losing INSERT's constraint-violation error is mapped to the +// same ErrWebPushAlreadyProvisioned this fast-path returns. +func (s *NotificationService) ProvisionWebPush(name, vapidSubject string) (*models.NotificationProvider, error) { + name = strings.TrimSpace(name) + vapidSubject = strings.TrimSpace(vapidSubject) + + if name == "" { + return nil, fmt.Errorf("%w: name is required", ErrWebPushInvalidRequest) + } + if !strings.HasPrefix(vapidSubject, "mailto:") && !strings.HasPrefix(vapidSubject, "https://") { + return nil, fmt.Errorf(`%w: vapid_subject must start with "mailto:" or "https://"`, ErrWebPushInvalidRequest) + } + + var count int64 + if err := s.DB.Model(&models.NotificationProvider{}).Where("type = ?", "webpush").Count(&count).Error; err != nil { + return nil, fmt.Errorf("check existing web push provider: %w", err) + } + if count > 0 { + return nil, ErrWebPushAlreadyProvisioned + } + + pub, priv, err := webpush.GenerateVAPIDKeyPair() + if err != nil { + return nil, fmt.Errorf("generate VAPID keypair: %w", err) + } + + serviceConfigJSON, err := json.Marshal(webpushServiceConfig{VAPIDPublicKey: pub, VAPIDSubject: vapidSubject}) + if err != nil { + return nil, fmt.Errorf("encode web push service config: %w", err) + } + + provider := &models.NotificationProvider{ + Name: name, + Type: "webpush", + Token: priv, + ServiceConfig: string(serviceConfigJSON), + Template: "minimal", + Enabled: true, + NotifyProxyHosts: true, + NotifyRemoteServers: true, + NotifyDomains: true, + NotifyCerts: true, + NotifyUptime: true, + } + + if err := s.CreateProvider(provider); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(err.Error(), "UNIQUE constraint failed") { + return nil, ErrWebPushAlreadyProvisioned + } + return nil, fmt.Errorf("create web push provider: %w", err) + } + + return provider, nil +} + +// getWebPushProvider loads the singleton Type="webpush" provider row +// regardless of its Enabled state — callers that need to distinguish +// "not provisioned" from "provisioned but disabled" (§3.4.3) check Enabled +// themselves; GetWebPushVAPIDPublicKey instead filters Enabled in its own +// query since both cases collapse to the same 404 there (§3.4.2). +func (s *NotificationService) getWebPushProvider() (*models.NotificationProvider, error) { + var provider models.NotificationProvider + if err := s.DB.Where("type = ?", "webpush").First(&provider).Error; err != nil { + return nil, err + } + return &provider, nil +} + +// GetWebPushVAPIDPublicKey returns the current VAPID public key for +// PushManager.subscribe (§3.4.2). Returns ErrWebPushNotProvisioned if no +// webpush provider row exists yet, or if it exists but Enabled is false +// (subscribing while disabled would create dead-on-arrival subscriptions) +// — the caller cannot distinguish the two cases and does not need to. +func (s *NotificationService) GetWebPushVAPIDPublicKey() (string, error) { + var provider models.NotificationProvider + if err := s.DB.Where("type = ? AND enabled = ?", "webpush", true).First(&provider).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", ErrWebPushNotProvisioned + } + return "", fmt.Errorf("load web push provider: %w", err) + } + + var cfg webpushServiceConfig + if err := json.Unmarshal([]byte(provider.ServiceConfig), &cfg); err != nil { + return "", fmt.Errorf("parse web push service config: %w", err) + } + if strings.TrimSpace(cfg.VAPIDPublicKey) == "" { + return "", ErrWebPushNotProvisioned + } + return cfg.VAPIDPublicKey, nil +} + +// WebPushSubscribeInput is the validated shape of a browser's +// PushSubscription.toJSON() payload (§3.4.3), decoupled from the HTTP +// request/JSON binding struct so the service layer has no gin dependency. +type WebPushSubscribeInput struct { + Endpoint string + P256dh string + Auth string + UserAgent string +} + +// RegisterWebPushSubscription creates (or idempotently re-registers) one +// browser's WebPushSubscription row under the current singleton webpush +// provider (§3.4.3). A second registration of the same Endpoint refreshes +// LastSeenAt/UserAgent/owner and resets FailureCount rather than creating a +// duplicate row — the same browser subscribing twice must not receive the +// same push twice. +func (s *NotificationService) RegisterWebPushSubscription(userID string, input WebPushSubscribeInput) (sub *models.WebPushSubscription, created bool, err error) { + userID = strings.TrimSpace(userID) + endpoint := strings.TrimSpace(input.Endpoint) + p256dh := strings.TrimSpace(input.P256dh) + authSecret := strings.TrimSpace(input.Auth) + + if userID == "" { + return nil, false, fmt.Errorf("%w: user is required", ErrWebPushInvalidRequest) + } + if endpoint == "" { + return nil, false, fmt.Errorf("%w: endpoint is required", ErrWebPushInvalidRequest) + } + parsedEndpoint, parseErr := neturl.Parse(endpoint) + if parseErr != nil || !strings.EqualFold(parsedEndpoint.Scheme, "https") || parsedEndpoint.Host == "" { + return nil, false, fmt.Errorf("%w: endpoint must be a valid https:// URL", ErrWebPushInvalidRequest) + } + if p256dh == "" || authSecret == "" { + return nil, false, fmt.Errorf("%w: keys.p256dh and keys.auth are required", ErrWebPushInvalidRequest) + } + + provider, err := s.getWebPushProvider() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, ErrWebPushNotProvisioned + } + return nil, false, fmt.Errorf("load web push provider: %w", err) + } + if !provider.Enabled { + return nil, false, ErrWebPushProviderDisabled + } + + now := time.Now() + + var existing models.WebPushSubscription + lookupErr := s.DB.Where("endpoint = ?", endpoint).First(&existing).Error + switch { + case lookupErr == nil: + existing.UserID = userID + existing.ProviderID = provider.ID + existing.P256dh = p256dh + existing.Auth = authSecret + existing.UserAgent = input.UserAgent + existing.LastSeenAt = now + existing.FailureCount = 0 + existing.LastFailureAt = nil + if err := s.DB.Save(&existing).Error; err != nil { + return nil, false, fmt.Errorf("update web push subscription: %w", err) + } + return &existing, false, nil + case errors.Is(lookupErr, gorm.ErrRecordNotFound): + newSub := &models.WebPushSubscription{ + ProviderID: provider.ID, + UserID: userID, + Endpoint: endpoint, + P256dh: p256dh, + Auth: authSecret, + UserAgent: input.UserAgent, + LastSeenAt: now, + } + if err := s.DB.Create(newSub).Error; err != nil { + return nil, false, fmt.Errorf("create web push subscription: %w", err) + } + return newSub, true, nil + default: + return nil, false, fmt.Errorf("look up web push subscription: %w", lookupErr) + } +} + +// ListWebPushSubscriptionsForUser returns only the caller's own +// subscriptions (§3.4.4) — never another user's rows, reinforcing per-user +// device management without a cross-user admin view in this PR. +func (s *NotificationService) ListWebPushSubscriptionsForUser(userID string) ([]models.WebPushSubscription, error) { + var subs []models.WebPushSubscription + if err := s.DB.Where("user_id = ?", strings.TrimSpace(userID)).Order("created_at desc").Find(&subs).Error; err != nil { + return nil, fmt.Errorf("list web push subscriptions: %w", err) + } + return subs, nil +} + +// DeleteWebPushSubscription removes a subscription owned by userID. +// Scoping the DELETE to both id and user_id in one query (rather than a +// separate ownership-check read) means a foreign ID naturally produces the +// same "0 rows affected" outcome as a nonexistent ID — collapsing "not +// found" and "not yours" into the same ErrWebPushSubscriptionNotFound / +// 404, consistent with this codebase's existing +// respondSanitizedProviderError convention of not leaking cross-tenant +// existence (§3.4.5). +func (s *NotificationService) DeleteWebPushSubscription(userID, id string) error { + result := s.DB.Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + Delete(&models.WebPushSubscription{}) + if result.Error != nil { + return fmt.Errorf("delete web push subscription: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrWebPushSubscriptionNotFound + } + return nil +} From 57eb30545ba047f63d2ed598dbdc23207db71f63 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Tue, 15 Sep 2026 01:43:31 +0000 Subject: [PATCH 07/13] feat: add web push service worker and subscribe/unsubscribe UI Adds the frontend half of Web Push notifications: a minimal service worker (push/notificationclick), a base64url-to-Uint8Array helper for the VAPID public key, typed API client functions for the five backend webpush endpoints, and Notifications page UI for admin-only provisioning, per-device subscribe/unsubscribe, and the not-provisioned/unsupported-browser states. Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN --- frontend/public/sw.js | 52 +++ frontend/src/api/notifications.test.ts | 64 ++++ frontend/src/api/notifications.ts | 74 +++- ...SecurityNotificationSettingsModal.test.tsx | 4 + frontend/src/pages/Notifications.tsx | 318 ++++++++++++++--- .../pages/__tests__/Notifications.test.tsx | 14 +- .../__tests__/Notifications.webpush.test.tsx | 332 ++++++++++++++++++ frontend/src/utils/__tests__/webpush.test.ts | 38 ++ frontend/src/utils/webpush.ts | 24 ++ 9 files changed, 877 insertions(+), 43 deletions(-) create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/pages/__tests__/Notifications.webpush.test.tsx create mode 100644 frontend/src/utils/__tests__/webpush.test.ts create mode 100644 frontend/src/utils/webpush.ts diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 000000000..9eb85eaed --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,52 @@ +/* global self, clients */ +// Charon Web Push service worker. +// +// Served at the origin root (`/sw.js`) so `PushManager.subscribe` can +// register it with root scope, per the Web Push API's same-scope +// requirement (a service worker can only receive pushes for paths within +// its own registration scope). +// +// The payload JSON shape matches the notify_yourself render package's +// `minimal`/`detailed` templates (`title`, `message`) — see +// `dispatchWebPushViaNotify` on the backend, which renders through the same +// shared template engine used by every other notification provider. + +self.addEventListener('push', (event) => { + let data = {}; + if (event.data) { + try { + data = event.data.json(); + } catch { + data = { message: event.data.text() }; + } + } + + const title = data.title || 'Charon'; + const options = { + body: data.message || data.body || '', + icon: '/favicon.png', + badge: '/favicon.png', + data, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil( + (async () => { + const allClients = await clients.matchAll({ type: 'window', includeUncontrolled: true }); + for (const client of allClients) { + if ('focus' in client) { + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow('/'); + } + return undefined; + })() + ); +}); diff --git a/frontend/src/api/notifications.test.ts b/frontend/src/api/notifications.test.ts index 5410d0244..85f757ff1 100644 --- a/frontend/src/api/notifications.test.ts +++ b/frontend/src/api/notifications.test.ts @@ -17,6 +17,11 @@ import { getSecurityNotificationSettings, updateSecurityNotificationSettings, SUPPORTED_NOTIFICATION_PROVIDER_TYPES, + provisionWebPush, + getWebPushVapidPublicKey, + subscribeWebPush, + listWebPushSubscriptions, + unsubscribeWebPush, } from './notifications' vi.mock('./client', () => ({ @@ -253,4 +258,63 @@ describe('notifications api', () => { token: 'new-token', }) }) + + it('webpush is in SUPPORTED_NOTIFICATION_PROVIDER_TYPES', () => { + expect(SUPPORTED_NOTIFICATION_PROVIDER_TYPES).toContain('webpush') + }) + + it('provisions the Web Push provider', async () => { + mockedClient.post.mockResolvedValue({ data: { id: 'wp1', name: 'Web Push', type: 'webpush', enabled: true, has_token: true } }) + + const provider = await provisionWebPush({ name: 'Web Push', vapid_subject: 'mailto:admin@example.com' }) + + expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/webpush/provision', { + name: 'Web Push', + vapid_subject: 'mailto:admin@example.com', + }) + expect(provider.id).toBe('wp1') + }) + + it('fetches the VAPID public key', async () => { + mockedClient.get.mockResolvedValue({ data: { vapid_public_key: 'abc123' } }) + + const result = await getWebPushVapidPublicKey() + + expect(mockedClient.get).toHaveBeenCalledWith('/notifications/providers/webpush/vapid-public-key') + expect(result.vapid_public_key).toBe('abc123') + }) + + it('subscribes a device for Web Push', async () => { + mockedClient.post.mockResolvedValue({ data: { id: 'sub1', endpoint: 'https://push.example.com/abc' } }) + + const payload = { + endpoint: 'https://push.example.com/abc', + keys: { p256dh: 'p256dh-key', auth: 'auth-key' }, + user_agent: 'test-agent', + } + const result = await subscribeWebPush(payload) + + expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers/webpush/subscriptions', payload) + expect(result).toEqual({ id: 'sub1', endpoint: 'https://push.example.com/abc' }) + }) + + it('lists the caller\'s Web Push subscriptions', async () => { + mockedClient.get.mockResolvedValue({ + data: [{ id: 'sub1', endpoint: 'https://push.example.com/abc', created_at: '2024-01-01T00:00:00Z', last_seen_at: '2024-01-02T00:00:00Z' }], + }) + + const result = await listWebPushSubscriptions() + + expect(mockedClient.get).toHaveBeenCalledWith('/notifications/providers/webpush/subscriptions') + expect(result).toHaveLength(1) + expect(result[0].id).toBe('sub1') + }) + + it('unsubscribes a Web Push subscription', async () => { + mockedClient.delete.mockResolvedValue({}) + + await unsubscribeWebPush('sub1') + + expect(mockedClient.delete).toHaveBeenCalledWith('/notifications/providers/webpush/subscriptions/sub1') + }) }) diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index d2a702435..f69b83cbe 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -1,6 +1,6 @@ import client from './client'; -export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover', 'ntfy'] as const; +export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover', 'ntfy', 'webpush'] as const; export type SupportedNotificationProviderType = (typeof SUPPORTED_NOTIFICATION_PROVIDER_TYPES)[number]; const DEFAULT_PROVIDER_TYPE: SupportedNotificationProviderType = 'discord'; @@ -269,3 +269,75 @@ export const updateSecurityNotificationSettings = async ( const response = await client.put('/notifications/settings/security', settings); return response.data; }; + +// Web Push provider +/** A single browser/device subscription registered for Web Push delivery. */ +export interface WebPushSubscription { + id: string; + endpoint: string; + user_agent?: string; + created_at: string; + last_seen_at: string; +} + +/** Payload shape for registering a new Web Push subscription, matching the browser's `PushSubscription.toJSON()` output. */ +export interface WebPushSubscriptionPayload { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; + user_agent?: string; +} + +/** + * Provisions the (singleton) Web Push notification provider. Admin-only — + * the backend route rejects non-admin callers with a 403. + * @param data - Provider name and the RFC 8292 VAPID subject (a `mailto:` or `https:` URI) + * @returns Promise resolving to the created NotificationProvider + * @throws {AxiosError} 409 if already provisioned, 400 for an invalid vapid_subject + */ +export const provisionWebPush = async (data: { name: string; vapid_subject: string }) => { + const response = await client.post('/notifications/providers/webpush/provision', data); + return response.data; +}; + +/** + * Fetches the public VAPID key needed to create a browser push subscription. + * @returns Promise resolving to the base64url-encoded VAPID public key + * @throws {AxiosError} 404 if Web Push has not been provisioned yet + */ +export const getWebPushVapidPublicKey = async () => { + const response = await client.get<{ vapid_public_key: string }>('/notifications/providers/webpush/vapid-public-key'); + return response.data; +}; + +/** + * Registers (or idempotently re-registers) this device's push subscription. + * @param subscription - The subscription details from `PushSubscription.toJSON()` plus the device's user agent + * @returns Promise resolving to the subscription's id and endpoint + * @throws {AxiosError} 404 if not provisioned, 503 if the provider is disabled, 400 for a malformed payload + */ +export const subscribeWebPush = async (subscription: WebPushSubscriptionPayload) => { + const response = await client.post<{ id: string; endpoint: string }>('/notifications/providers/webpush/subscriptions', subscription); + return response.data; +}; + +/** + * Fetches the caller's own Web Push subscriptions. + * @returns Promise resolving to an array of WebPushSubscription objects + * @throws {AxiosError} If the request fails + */ +export const listWebPushSubscriptions = async () => { + const response = await client.get('/notifications/providers/webpush/subscriptions'); + return response.data; +}; + +/** + * Deletes a Web Push subscription owned by the caller. + * @param id - The subscription ID to delete + * @throws {AxiosError} 404 if not found or not owned by the caller + */ +export const unsubscribeWebPush = async (id: string) => { + await client.delete(`/notifications/providers/webpush/subscriptions/${id}`); +}; diff --git a/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx b/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx index 4272bd930..b19642b3b 100644 --- a/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx +++ b/frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx @@ -30,6 +30,10 @@ vi.mock('../../utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, })); +vi.mock('../../hooks/useAuth', () => ({ + useAuth: () => ({ user: { id: 'u1', username: 'tester', role: 'admin' } }), +})); + describe('Security Notification Settings on Notifications page', () => { let queryClient: ReturnType; diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index 09ecb97d3..c8501d6af 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -1,13 +1,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react'; -import { useEffect, useState, type FC } from 'react'; +import { isAxiosError } from 'axios'; +import { Bell, BellRing, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react'; +import { useEffect, useState, type FC, type FormEvent } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, type NotificationProvider, getExternalTemplates, previewExternalTemplate, type ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate, type NotificationTemplate, SUPPORTED_NOTIFICATION_PROVIDER_TYPES, type SupportedNotificationProviderType } from '../api/notifications'; +import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, type NotificationProvider, getExternalTemplates, previewExternalTemplate, type ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate, type NotificationTemplate, SUPPORTED_NOTIFICATION_PROVIDER_TYPES, type SupportedNotificationProviderType, provisionWebPush, getWebPushVapidPublicKey, subscribeWebPush, listWebPushSubscriptions, unsubscribeWebPush, type WebPushSubscription } from '../api/notifications'; import { Button } from '../components/ui/Button'; import { Card } from '../components/ui/Card'; +import { useAuth } from '../hooks/useAuth'; import { toast } from '../utils/toast'; +import { urlBase64ToUint8Array } from '../utils/webpush'; const DISCORD_PROVIDER_TYPE: SupportedNotificationProviderType = 'discord'; @@ -23,7 +26,7 @@ const isSupportedProviderType = (providerType: string | undefined): providerType const supportsJSONTemplates = (providerType: string | undefined): boolean => { if (!providerType) return false; const t = providerType.toLowerCase(); - return t === 'discord' || t === 'gotify' || t === 'webhook' || t === 'telegram' || t === 'slack' || t === 'pushover' || t === 'ntfy'; + return t === 'discord' || t === 'gotify' || t === 'webhook' || t === 'telegram' || t === 'slack' || t === 'pushover' || t === 'ntfy' || t === 'webpush'; }; const isUnsupportedProviderType = (providerType: string | undefined): boolean => !isSupportedProviderType(providerType); @@ -150,6 +153,7 @@ const ProviderForm: FC<{ const isSlack = type === 'slack'; const isPushover = type === 'pushover'; const isNtfy = type === 'ntfy'; + const isWebPush = type === 'webpush'; const isNew = !watch('id'); useEffect(() => { if (type !== 'gotify' && type !== 'telegram' && type !== 'slack' && type !== 'pushover' && type !== 'ntfy') { @@ -201,6 +205,7 @@ const ProviderForm: FC<{ id="provider-type" {...register('type')} data-testid="provider-type" + disabled={isWebPush} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm" > @@ -211,46 +216,58 @@ const ProviderForm: FC<{ + {/* Web Push provisioning is a dedicated singleton flow (see the Web + Push panel below); this option only ever appears when editing + an already-provisioned row, never as a choice for a new one. */} + {isWebPush && } -
- - {isEmail && ( -

- {t('notificationProviders.recipientsHelp')} -

- )} - - {!isEmail && errors.url && ( - - {errors.url.message as string} - - )} -
+ {!isWebPush && ( +
+ + {isEmail && ( +

+ {t('notificationProviders.recipientsHelp')} +

+ )} + + {!isEmail && errors.url && ( + + {errors.url.message as string} + + )} +
+ )} + + {isWebPush && ( +

+ Web Push devices are managed from the Web Push panel above — subscribe or unsubscribe a device there. The settings below control which events this provider dispatches. +

+ )} {isEmail && (
@@ -481,8 +498,225 @@ const TemplateForm: FC<{ ); }; +// supportsWebPushBrowserApi returns true if this browser can register a +// service worker and subscribe to push notifications at all. +const supportsWebPushBrowserApi = (): boolean => + typeof navigator !== 'undefined' && 'serviceWorker' in navigator && typeof window !== 'undefined' && 'PushManager' in window; + +const WEBPUSH_SW_PATH = '/sw.js'; + +const WebPushCard: FC<{ isAdmin: boolean }> = ({ isAdmin }) => { + const queryClient = useQueryClient(); + const [provisionName, setProvisionName] = useState('Web Push'); + const [vapidSubject, setVapidSubject] = useState(''); + const [subscribeError, setSubscribeError] = useState(null); + + const browserSupported = supportsWebPushBrowserApi(); + + const vapidQuery = useQuery({ + queryKey: ['webpushVapidPublicKey'], + queryFn: getWebPushVapidPublicKey, + enabled: browserSupported, + retry: false, + }); + + const isProvisioned = Boolean(vapidQuery.data?.vapid_public_key); + const notProvisioned = vapidQuery.isError && isAxiosError(vapidQuery.error) && vapidQuery.error.response?.status === 404; + + const subscriptionsQuery = useQuery({ + queryKey: ['webpushSubscriptions'], + queryFn: listWebPushSubscriptions, + enabled: browserSupported && isProvisioned, + }); + + const provisionMutation = useMutation({ + mutationFn: provisionWebPush, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['webpushVapidPublicKey'] }); + queryClient.invalidateQueries({ queryKey: ['notificationProviders'] }); + toast.success('Web Push provisioned.'); + setVapidSubject(''); + }, + onError: (err: Error) => toast.error(err.message || 'Failed to provision Web Push.'), + }); + + const subscribeMutation = useMutation({ + mutationFn: subscribeWebPush, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['webpushSubscriptions'] }); + toast.success('This device is now subscribed to push notifications.'); + }, + }); + + const unsubscribeMutation = useMutation({ + mutationFn: unsubscribeWebPush, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['webpushSubscriptions'] }), + onError: (err: Error) => toast.error(err.message || 'Failed to remove subscription.'), + }); + + const handleProvision = (event: FormEvent) => { + event.preventDefault(); + provisionMutation.mutate({ name: provisionName.trim() || 'Web Push', vapid_subject: vapidSubject.trim() }); + }; + + const handleSubscribe = async () => { + setSubscribeError(null); + if (!vapidQuery.data?.vapid_public_key) return; + + try { + const permission = await Notification.requestPermission(); + if (permission !== 'granted') { + // Permission denial isn't a Charon-side error: show an inline + // message only, make no backend call, create no provider-side row. + setSubscribeError('Notification permission was not granted for this browser.'); + return; + } + + const registration = await navigator.serviceWorker.register(WEBPUSH_SW_PATH); + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + // Cast needed because TS's DOM lib types PushSubscriptionOptionsInit's + // applicationServerKey as BufferSource, which a generic + // Uint8Array doesn't structurally satisfy — the + // runtime value is a plain Uint8Array, which the Push API accepts. + applicationServerKey: urlBase64ToUint8Array(vapidQuery.data.vapid_public_key) as BufferSource, + }); + const json = subscription.toJSON(); + + await subscribeMutation.mutateAsync({ + endpoint: json.endpoint ?? '', + keys: { p256dh: json.keys?.p256dh ?? '', auth: json.keys?.auth ?? '' }, + user_agent: navigator.userAgent, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to subscribe this device.'; + setSubscribeError(msg); + toast.error(msg); + } + }; + + const handleUnsubscribe = async (subscription: WebPushSubscription) => { + try { + if (browserSupported) { + const registration = await navigator.serviceWorker.getRegistration(WEBPUSH_SW_PATH); + const activeSubscription = await registration?.pushManager.getSubscription(); + // Only the device whose active subscription matches this row's + // endpoint can be unsubscribed browser-side; rows for other devices + // are removed backend-only below. + if (activeSubscription && activeSubscription.endpoint === subscription.endpoint) { + await activeSubscription.unsubscribe(); + } + } + } catch { + // Browser-side cleanup is best-effort; the backend row is still removed. + } finally { + unsubscribeMutation.mutate(subscription.id); + } + }; + + return ( + +

+ + Web Push +

+ + {!browserSupported && ( +

+ This browser does not support Web Push notifications. +

+ )} + + {browserSupported && vapidQuery.isLoading && ( +

Loading…

+ )} + + {browserSupported && notProvisioned && ( + isAdmin ? ( +
+

Web Push has not been set up yet. Provision it once to enable browser push notifications.

+
+ + setProvisionName(event.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm" + /> +
+
+ + setVapidSubject(event.target.value)} + placeholder="mailto:admin@example.com" + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm" + /> +
+ +
+ ) : ( +

+ Web Push has not been set up yet. Ask an administrator to provision it. +

+ ) + )} + + {browserSupported && isProvisioned && ( +
+
+ + {subscribeError && ( +

{subscribeError}

+ )} +
+ +
+

Subscribed devices

+
+ {subscriptionsQuery.data?.map((subscription) => ( +
+ + {subscription.user_agent || subscription.endpoint} + + +
+ ))} + {subscriptionsQuery.data?.length === 0 && ( +

No devices subscribed yet.

+ )} +
+
+
+ )} +
+ ); +}; + const Notifications: FC = () => { const { t } = useTranslation(); + const { user } = useAuth(); + const isAdmin = user?.role === 'admin'; const queryClient = useQueryClient(); const [isAdding, setIsAdding] = useState(false); const [editingId, setEditingId] = useState(null); @@ -565,6 +799,8 @@ const Notifications: FC = () => {
+ + {/* External Templates Management */}

{t('notificationProviders.externalTemplates')}

diff --git a/frontend/src/pages/__tests__/Notifications.test.tsx b/frontend/src/pages/__tests__/Notifications.test.tsx index 844a02164..92b7eed16 100644 --- a/frontend/src/pages/__tests__/Notifications.test.tsx +++ b/frontend/src/pages/__tests__/Notifications.test.tsx @@ -16,7 +16,7 @@ vi.mock('react-i18next', () => ({ })) vi.mock('../../api/notifications', () => ({ - SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover', 'ntfy'], + SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover', 'ntfy', 'webpush'], getProviders: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn(), @@ -29,6 +29,11 @@ vi.mock('../../api/notifications', () => ({ createExternalTemplate: vi.fn(), updateExternalTemplate: vi.fn(), deleteExternalTemplate: vi.fn(), + provisionWebPush: vi.fn(), + getWebPushVapidPublicKey: vi.fn(), + subscribeWebPush: vi.fn(), + listWebPushSubscriptions: vi.fn(), + unsubscribeWebPush: vi.fn(), })) vi.mock('../../utils/toast', () => ({ @@ -38,6 +43,11 @@ vi.mock('../../utils/toast', () => ({ }, })) +const mockUseAuth = vi.fn() +vi.mock('../../hooks/useAuth', () => ({ + useAuth: () => mockUseAuth(), +})) + const baseProvider: NotificationProvider = { id: 'provider-1', name: 'Discord Alerts', @@ -63,6 +73,8 @@ const setupMocks = (providers: NotificationProvider[] = []) => { vi.mocked(notificationsApi.getExternalTemplates).mockResolvedValue([]) vi.mocked(notificationsApi.createProvider).mockResolvedValue(baseProvider) vi.mocked(notificationsApi.updateProvider).mockResolvedValue(baseProvider) + vi.mocked(notificationsApi.listWebPushSubscriptions).mockResolvedValue([]) + mockUseAuth.mockReturnValue({ user: { id: 'u1', username: 'admin', role: 'admin' } }) } let user: ReturnType diff --git a/frontend/src/pages/__tests__/Notifications.webpush.test.tsx b/frontend/src/pages/__tests__/Notifications.webpush.test.tsx new file mode 100644 index 000000000..bf295af92 --- /dev/null +++ b/frontend/src/pages/__tests__/Notifications.webpush.test.tsx @@ -0,0 +1,332 @@ +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import * as notificationsApi from '../../api/notifications' +import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient' +import { toast } from '../../utils/toast' +import Notifications from '../Notifications' + +import type { NotificationProvider, WebPushSubscription } from '../../api/notifications' + +vi.mock('../../api/notifications', () => ({ + SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover', 'ntfy', 'webpush'], + getProviders: vi.fn(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + deleteProvider: vi.fn(), + testProvider: vi.fn(), + getTemplates: vi.fn(), + previewProvider: vi.fn(), + getExternalTemplates: vi.fn(), + previewExternalTemplate: vi.fn(), + createExternalTemplate: vi.fn(), + updateExternalTemplate: vi.fn(), + deleteExternalTemplate: vi.fn(), + provisionWebPush: vi.fn(), + getWebPushVapidPublicKey: vi.fn(), + subscribeWebPush: vi.fn(), + listWebPushSubscriptions: vi.fn(), + unsubscribeWebPush: vi.fn(), +})) + +vi.mock('../../utils/toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +const mockUseAuth = vi.fn() +vi.mock('../../hooks/useAuth', () => ({ + useAuth: () => mockUseAuth(), +})) + +const notProvisionedError = Object.assign(new Error('Not Found'), { + isAxiosError: true, + response: { status: 404, data: { error: 'not provisioned' } }, +}) + +const baseSubscription: WebPushSubscription = { + id: 'sub-1', + endpoint: 'https://push.example.com/abc', + user_agent: 'Mozilla/5.0 Test Browser', + created_at: '2026-01-01T00:00:00Z', + last_seen_at: '2026-01-02T00:00:00Z', +} + +const mockPushManager = { + subscribe: vi.fn(), + getSubscription: vi.fn(), +} + +const mockRegistration = { + pushManager: mockPushManager, +} + +const mockServiceWorker = { + register: vi.fn().mockResolvedValue(mockRegistration), + getRegistration: vi.fn().mockResolvedValue(mockRegistration), +} + +const setSupportsWebPush = (supported: boolean) => { + if (supported) { + vi.stubGlobal('PushManager', function PushManager() {}) + Object.defineProperty(navigator, 'serviceWorker', { + value: mockServiceWorker, + configurable: true, + writable: true, + }) + } else { + vi.unstubAllGlobals() + Object.defineProperty(navigator, 'serviceWorker', { + value: undefined, + configurable: true, + writable: true, + }) + } +} + +const setupMocks = (options: { providers?: NotificationProvider[]; role?: 'admin' | 'user'; subscriptions?: WebPushSubscription[] } = {}) => { + const { providers = [], role = 'admin', subscriptions = [] } = options + vi.mocked(notificationsApi.getProviders).mockResolvedValue(providers) + vi.mocked(notificationsApi.getTemplates).mockResolvedValue([]) + vi.mocked(notificationsApi.getExternalTemplates).mockResolvedValue([]) + vi.mocked(notificationsApi.listWebPushSubscriptions).mockResolvedValue(subscriptions) + mockUseAuth.mockReturnValue({ user: { id: 'u1', username: 'tester', role } }) +} + +let user: ReturnType + +describe('Notifications - Web Push', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('Notification', { requestPermission: vi.fn().mockResolvedValue('granted') }) + mockPushManager.subscribe.mockReset() + mockPushManager.getSubscription.mockReset() + mockServiceWorker.register.mockClear().mockResolvedValue(mockRegistration) + mockServiceWorker.getRegistration.mockClear().mockResolvedValue(mockRegistration) + setupMocks() + user = userEvent.setup() + }) + + it('shows an unsupported-browser message when serviceWorker/PushManager are unavailable', async () => { + setSupportsWebPush(false) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockRejectedValue(notProvisionedError) + + renderWithQueryClient() + + expect(await screen.findByTestId('webpush-unsupported')).toBeInTheDocument() + expect(notificationsApi.getWebPushVapidPublicKey).not.toHaveBeenCalled() + }) + + it('shows a provision form to admins when Web Push has not been provisioned', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockRejectedValue(notProvisionedError) + + renderWithQueryClient() + + expect(await screen.findByTestId('webpush-provision-form')).toBeInTheDocument() + expect(screen.queryByTestId('webpush-not-provisioned-message')).not.toBeInTheDocument() + }) + + it('shows a plain message (no provision form) to non-admins when not provisioned', async () => { + setSupportsWebPush(true) + setupMocks({ role: 'user' }) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockRejectedValue(notProvisionedError) + + renderWithQueryClient() + + expect(await screen.findByTestId('webpush-not-provisioned-message')).toBeInTheDocument() + expect(screen.queryByTestId('webpush-provision-form')).not.toBeInTheDocument() + }) + + it('provisions Web Push when the admin submits the form', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockRejectedValue(notProvisionedError) + vi.mocked(notificationsApi.provisionWebPush).mockResolvedValue({ + id: 'wp-1', + name: 'Web Push', + type: 'webpush', + url: '', + enabled: true, + has_token: true, + notify_proxy_hosts: true, + notify_remote_servers: true, + notify_domains: true, + notify_certs: true, + notify_uptime: true, + notify_security_waf_blocks: false, + notify_security_acl_denies: false, + notify_security_rate_limit_hits: false, + created_at: '2026-01-01T00:00:00Z', + }) + + renderWithQueryClient() + + await screen.findByTestId('webpush-provision-form') + await user.clear(screen.getByTestId('webpush-provision-name')) + await user.type(screen.getByTestId('webpush-provision-name'), 'Team Push') + await user.clear(screen.getByTestId('webpush-vapid-subject')) + await user.type(screen.getByTestId('webpush-vapid-subject'), 'mailto:admin@example.com') + await user.click(screen.getByTestId('webpush-provision-btn')) + + await waitFor(() => { + expect(notificationsApi.provisionWebPush).toHaveBeenCalled() + }) + expect(vi.mocked(notificationsApi.provisionWebPush).mock.calls[0][0]).toEqual({ name: 'Team Push', vapid_subject: 'mailto:admin@example.com' }) + expect(toast.success).toHaveBeenCalled() + }) + + it('shows an error toast and keeps the form open when provisioning fails', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockRejectedValue(notProvisionedError) + vi.mocked(notificationsApi.provisionWebPush).mockRejectedValue(new Error('vapid_subject must be a mailto: or https: URI')) + + renderWithQueryClient() + + await screen.findByTestId('webpush-provision-form') + await user.type(screen.getByTestId('webpush-vapid-subject'), 'not-a-valid-uri') + await user.click(screen.getByTestId('webpush-provision-btn')) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('vapid_subject must be a mailto: or https: URI') + }) + expect(screen.getByTestId('webpush-provision-form')).toBeInTheDocument() + }) + + it('shows the subscribe control and an empty subscriptions list once provisioned', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + + renderWithQueryClient() + + expect(await screen.findByTestId('webpush-subscribe-btn')).toBeInTheDocument() + expect(await screen.findByTestId('webpush-no-subscriptions')).toBeInTheDocument() + }) + + it('subscribes this device when clicking the subscribe button', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + mockPushManager.subscribe.mockResolvedValue({ + toJSON: () => ({ endpoint: 'https://push.example.com/new', keys: { p256dh: 'p-key', auth: 'a-key' } }), + }) + vi.mocked(notificationsApi.subscribeWebPush).mockResolvedValue({ id: 'sub-new', endpoint: 'https://push.example.com/new' }) + + renderWithQueryClient() + + await user.click(await screen.findByTestId('webpush-subscribe-btn')) + + await waitFor(() => { + expect(notificationsApi.subscribeWebPush).toHaveBeenCalled() + }) + expect(vi.mocked(notificationsApi.subscribeWebPush).mock.calls[0][0]).toEqual({ + endpoint: 'https://push.example.com/new', + keys: { p256dh: 'p-key', auth: 'a-key' }, + user_agent: navigator.userAgent, + }) + expect(mockServiceWorker.register).toHaveBeenCalledWith('/sw.js') + }) + + it('shows an inline error and a toast when the browser subscribe call fails', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + mockPushManager.subscribe.mockRejectedValue(new Error('AbortError: subscribe failed')) + + renderWithQueryClient() + + await user.click(await screen.findByTestId('webpush-subscribe-btn')) + + expect(await screen.findByTestId('webpush-subscribe-error')).toHaveTextContent('AbortError: subscribe failed') + expect(toast.error).toHaveBeenCalledWith('AbortError: subscribe failed') + expect(notificationsApi.subscribeWebPush).not.toHaveBeenCalled() + }) + + it('shows an inline message and makes no backend call when permission is denied', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + vi.stubGlobal('Notification', { requestPermission: vi.fn().mockResolvedValue('denied') }) + + renderWithQueryClient() + + await user.click(await screen.findByTestId('webpush-subscribe-btn')) + + expect(await screen.findByTestId('webpush-subscribe-error')).toBeInTheDocument() + expect(notificationsApi.subscribeWebPush).not.toHaveBeenCalled() + }) + + it('lists existing subscriptions and unsubscribes a row', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + setupMocks({ subscriptions: [baseSubscription] }) + mockPushManager.getSubscription.mockResolvedValue(null) + vi.mocked(notificationsApi.unsubscribeWebPush).mockResolvedValue(undefined) + + renderWithQueryClient() + + const row = await screen.findByTestId('webpush-subscription-row-sub-1') + expect(within(row).getByText('Mozilla/5.0 Test Browser')).toBeInTheDocument() + + await user.click(within(row).getByTestId('webpush-unsubscribe-sub-1')) + + await waitFor(() => { + expect(notificationsApi.unsubscribeWebPush).toHaveBeenCalled() + }) + expect(vi.mocked(notificationsApi.unsubscribeWebPush).mock.calls[0][0]).toBe('sub-1') + }) + + it('unsubscribes browser-side when the row matches this device\'s active subscription', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + setupMocks({ subscriptions: [baseSubscription] }) + const activeUnsubscribe = vi.fn().mockResolvedValue(true) + mockPushManager.getSubscription.mockResolvedValue({ endpoint: baseSubscription.endpoint, unsubscribe: activeUnsubscribe }) + vi.mocked(notificationsApi.unsubscribeWebPush).mockResolvedValue(undefined) + + renderWithQueryClient() + + const row = await screen.findByTestId('webpush-subscription-row-sub-1') + await user.click(within(row).getByTestId('webpush-unsubscribe-sub-1')) + + await waitFor(() => { + expect(activeUnsubscribe).toHaveBeenCalled() + expect(notificationsApi.unsubscribeWebPush).toHaveBeenCalled() + }) + expect(vi.mocked(notificationsApi.unsubscribeWebPush).mock.calls[0][0]).toBe('sub-1') + }) + + it('shows a toast when removing a subscription fails', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + setupMocks({ subscriptions: [baseSubscription] }) + mockPushManager.getSubscription.mockResolvedValue(null) + vi.mocked(notificationsApi.unsubscribeWebPush).mockRejectedValue(new Error('Subscription not found')) + + renderWithQueryClient() + + const row = await screen.findByTestId('webpush-subscription-row-sub-1') + await user.click(within(row).getByTestId('webpush-unsubscribe-sub-1')) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Subscription not found') + }) + }) + + it('still removes the backend row when browser-side cleanup throws', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + setupMocks({ subscriptions: [baseSubscription] }) + mockPushManager.getSubscription.mockRejectedValue(new Error('registration lookup failed')) + vi.mocked(notificationsApi.unsubscribeWebPush).mockResolvedValue(undefined) + + renderWithQueryClient() + + const row = await screen.findByTestId('webpush-subscription-row-sub-1') + await user.click(within(row).getByTestId('webpush-unsubscribe-sub-1')) + + await waitFor(() => { + expect(notificationsApi.unsubscribeWebPush).toHaveBeenCalled() + }) + expect(vi.mocked(notificationsApi.unsubscribeWebPush).mock.calls[0][0]).toBe('sub-1') + }) +}) diff --git a/frontend/src/utils/__tests__/webpush.test.ts b/frontend/src/utils/__tests__/webpush.test.ts new file mode 100644 index 000000000..463abb0ee --- /dev/null +++ b/frontend/src/utils/__tests__/webpush.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' + +import { urlBase64ToUint8Array } from '../webpush' + +describe('urlBase64ToUint8Array', () => { + it('decodes a base64url string with no padding required', () => { + // 'Zm9vYmFy' -> "foobar" in standard base64, already a multiple of 4. + const result = urlBase64ToUint8Array('Zm9vYmFy') + expect(Array.from(result)).toEqual([102, 111, 111, 98, 97, 114]) + }) + + it('decodes a base64url string that requires padding', () => { + // 'Zm9v' decodes to "foo" (3 bytes) without needing padding, so use a + // length that actually requires the base64 padding branch. + const result = urlBase64ToUint8Array('Zm9vYg') + expect(Array.from(result)).toEqual([102, 111, 111, 98]) + }) + + it('replaces URL-safe characters (- and _) before decoding', () => { + // Bytes 0xFB 0xFF encode to base64 "-_8=" -> base64url "-_8". + const result = urlBase64ToUint8Array('-_8') + expect(Array.from(result)).toEqual([0xfb, 0xff]) + }) + + it('decodes an empty string to an empty array', () => { + const result = urlBase64ToUint8Array('') + expect(result.length).toBe(0) + }) + + it('round-trips a realistic VAPID-key-length value', () => { + // A 65-byte uncompressed P-256 public key, base64url-encoded (no padding), + // is the real-world shape this helper receives from the backend. + const bytes = Array.from({ length: 65 }, (_, i) => i % 256) + const base64url = btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + const result = urlBase64ToUint8Array(base64url) + expect(Array.from(result)).toEqual(bytes) + }) +}) diff --git a/frontend/src/utils/webpush.ts b/frontend/src/utils/webpush.ts new file mode 100644 index 000000000..dea0aa486 --- /dev/null +++ b/frontend/src/utils/webpush.ts @@ -0,0 +1,24 @@ +/** + * Converts a base64url-encoded string (as returned by the Web Push + * VAPID public key API) into a Uint8Array. + * + * The browser `PushManager.subscribe({ applicationServerKey })` API + * requires the raw key bytes, not the base64url string Charon stores and + * serves — this is standard boilerplate for every Web Push frontend + * integration (not Charon-specific). + * + * @param base64String - A base64url-encoded string (RFC 4648 §5), optionally + * missing its trailing `=` padding. + * @returns The decoded bytes as a Uint8Array. + */ +export const urlBase64ToUint8Array = (base64String: string): Uint8Array => { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; i++) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; From 2958b6074679acd7c8302d28f66d175a38df2180 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Tue, 15 Sep 2026 06:48:50 +0000 Subject: [PATCH 08/13] docs: update web push spec with review resolutions Reflects the supervisor review pass: DB-level singleton enforcement via partial unique index, the resolved auth-scoping decision, and a documented (non-blocking) risk note on VAPID key plaintext storage. Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN --- docs/plans/current_spec.md | 2049 ++++++++++++++++++------------------ 1 file changed, 1006 insertions(+), 1043 deletions(-) diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 3e2199d2a..bf8f3a58b 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,9 +1,8 @@ -# Technical Spec — GHSA-3gc6-295r-xm5m Fix + Management-API Authorization Hardening + Retire Public Registration +# Technical Spec — Web Push Notification Provider (go_notify_yourself v0.3.0) -**Status:** Draft for review (revised per coordinator rulings 2026-09-08) -**Advisory:** GHSA-3gc6-295r-xm5m — "Improper Authorization on CrowdSec Admin APIs via Public User Registration" (CWE-862, CVSS 8.8, reporter EQSTLab) +**Status:** Draft for review **Scope model:** ONE feature = ONE PR, delivered as an ordered sequence of logical commits (see [§9 Commit Slicing Strategy](#9-commit-slicing-strategy)). No PR splitting. -**Branch:** `development` (per `CLAUDE.md`: no worktrees, work on the current branch). +**Branch:** `development` (per `CLAUDE.md`: no worktrees, work on the current working branch). --- @@ -11,1102 +10,1066 @@ ### 1.1 Overview -A publicly reachable `POST /api/v1/auth/register` lets an anonymous attacker -create a `role=user` account. That account then reaches the entire -`/api/v1/admin/crowdsec/*` surface (~45 routes) because those routes are mounted -on the bare `management` router group, which is guarded only by -`RequireManagementAccess()` (rejects `role=passthrough` only — `role=user` -passes). Impact: bouncer API-key disclosure, disabling the IPS -(`POST /admin/crowdsec/stop` persists `SecurityConfig.Enabled=false`), ban -add/remove, and CrowdSec config-file read/write. - -This feature: - -- **Part A** — closes the authorization hole (the advisory fix): mount the - CrowdSec admin routes behind an explicit `RequireRole(admin)` subgroup, - mirroring the existing `securityAdmin` pattern. -- **Part B** — audits every route on the `management` group for the same class - of bug, fixes each under-guarded route found (at minimum: the - `/admin/plugins` mutation routes, a confirmed second live instance), and - introduces a "deny-by-default" structural guard + enforcement test so a - handler can no longer accidentally land privileged routes on an under-guarded - group. -- **Part C** — **removes the public `POST /auth/register` endpoint entirely** - (coordinator ruling). First-admin bootstrap continues via `POST /setup`; - post-bootstrap account creation is served by the **existing** admin - invite-user / email-invite flow (`User.InviteToken`, - `UserHandler.InviteUser` / `ValidateInvite` / `AcceptInvite`, - `frontend/src/pages/AcceptInvite.tsx`). No new invite model / service / - endpoints / UI are built. +`go_notify_yourself` v0.3.0 (released, local clone verified at tag `v0.3.0`, +commit `9411a45`) adds a `providers/webpush` package implementing direct +browser Web Push (RFC 8030 transport, RFC 8291 payload encryption, RFC 8292 +VAPID JWT auth) — no third-party relay. Charon currently pins v0.2.2. + +This feature adds Web Push as a ninth notification provider type so a +Charon admin can receive host-down/cert-expiry/security-event alerts as +native OS/browser push notifications, without installing a +Telegram/Discord/Pushover account. It requires: + +1. Bumping `go.mod` to `go_notify_yourself v0.3.0`. +2. A data model resolving Web Push's one-VAPID-identity-to-N-subscriptions + shape against Charon's existing one-row-per-destination + `NotificationProvider` table. +3. New backend endpoints for VAPID public-key distribution and subscription + lifecycle (register/unregister), wired through the existing + authenticated `management` route group. +4. A frontend service worker + subscribe/unsubscribe UI in `Notifications.tsx`. +5. Allowlist wiring through every gate `notification_service.go` already + enforces per provider type (blank import, supported-type switch, + dispatch-enabled feature flag, JSON-template support, config-field + mapping). ### 1.2 Objectives / Goals -1. A `role=user` (or unauthenticated) caller receives `403` on every CrowdSec - admin route; `role=admin` is unaffected. -2. Every state-changing / privileged route on `management` is provably - admin-guarded or is a deliberate, documented `role=user` capability, enforced - by a CI test. -3. `POST /api/v1/auth/register` no longer exists — the route returns `404`. -4. First-admin bootstrap (`POST /setup`) and the existing email-invite - acceptance flow (`GET /invite/validate`, `POST /invite/accept`) continue to - work unchanged. -5. Backend coverage ≥ 85 %, frontend coverage ≥ 85 %, targeted E2E green, all - Definition-of-Done gates pass. - -### 1.3 Non-goals - -- Any new invite mechanism, model, service, endpoint, or UI. (Earlier draft's - `models.Invite` / `InviteService` / `InviteHandler` / `frontend/src/api/invites.ts` / - `useInvites` / `UsersPage` invite section / `/register` page are **dropped**.) -- Changes to the existing per-user email-invite flow beyond referencing it as - the supported post-bootstrap path. -- A general-purpose RBAC engine. The 3-tier model (`admin` / `user` / - `passthrough`) is unchanged. -- Per-IP auth rate limiting (see [§7](#7-remaining-open-questions) — deferred to - a follow-up issue; `/auth/register` is being removed and `/auth/login` - already has account lockout). +1. An admin can enable Web Push from the Notifications page, generating (or + using an existing) app-wide VAPID identity with zero manual key entry. +2. An admin's browser can subscribe/unsubscribe independently per + device/browser profile; multiple admins/devices can hold independent + subscriptions simultaneously. +3. `SendExternal` fans a single logical notification out to every active + subscription under the Web Push provider row, respecting the same + per-event-type preference toggles (`NotifyProxyHosts`, `NotifyCerts`, + etc.) every other provider type already has. +4. A subscription the push service reports as dead (404/410) is pruned + automatically on next send, without operator intervention. +5. No behavior change to any of the other 8 provider types. +6. Full Definition of Done passes: 85% coverage, staticcheck clean, E2E + specs for the new flow, type-check clean, GORM security scan clean + (new model + migration). + +### 1.3 Non-Goals + +- No push-notification support for anonymous/unauthenticated visitors — + subscriptions are created by an authenticated Charon user's browser only + (see §3.6 auth model). +- No mobile app / native push (APNs/FCM SDK) — this is purely W3C Push API + in a browser context, which is what `providers/webpush` implements. +- No UI for editing an individual subscription's delivery hints (TTL, + Urgency, Topic) — Charon sets sane fixed defaults; only VAPID identity + and per-event-type preferences are admin-configurable, consistent with + how other providers expose no per-message delivery-hint UI either. +- No automated VAPID key rotation UI in this PR (see §7 Risks — flagged as + a documented follow-up, not silently deferred). --- ## 2. Research Findings -### 2.1 Existing architecture (verified in-repo on `development`) - -#### Auth / authorization primitives - -| Element | Location | Behavior | -|---|---|---| -| `AuthMiddleware` | `backend/internal/api/middleware/auth.go` | Validates JWT / cookie, sets `c.Set("userID", …)` and `c.Set("role", string(user.Role))`. | -| `RequireManagementAccess()` | `backend/internal/api/middleware/auth.go:116` | **Only** aborts when `role == RolePassthrough`. `role=user` and `role=admin` pass. | -| `RequireRole(role)` | `backend/internal/api/middleware/auth.go` | Aborts `401` if no role; aborts `403` unless `userRole == role` **or** `userRole == RoleAdmin`. So `RequireRole(RoleAdmin)` ⇒ admin-only, and `RequireRole(anything)` still lets admin through. | -| `requireAdmin(c)` / `isAdmin(c)` | `backend/internal/api/handlers/permission_helpers.go` | In-handler guard. `isAdmin` = `c.GetString("role") == "admin"`. `requireAdmin` writes `403 {"error":"admin privileges required","error_code":"permissions_admin_only"}`. | -| `rejectPassthrough(c, action)` | `backend/internal/api/handlers/user_handler.go:225` | In-handler 403 for passthrough. | -| Roles | `backend/internal/models/user.go` | `RoleAdmin="admin"`, `RoleUser="user"`, `RolePassthrough="passthrough"`. `RoleUser` doc: "can access the Charon management UI with restricted permissions" (restriction is per-host `PermittedHosts`, not per-feature). | - -#### Route groups — `backend/internal/api/routes/routes.go` - -``` -api := router.Group("/api/v1") // public - api.POST("/auth/login", …) - api.POST("/auth/register", authHandler.Register) // line 295 — PUBLIC, no gate ← ADVISORY (Part C removes) - api.GET("/setup", …) / api.POST("/setup", …) // bootstrap first admin (Part C keeps) - api.GET("/invite/validate", …) / api.POST("/invite/accept", …) // existing email-invite (Part C references as supported path) - protected := api.Group("/"); protected.Use(authMiddleware) // any authenticated user - management := protected.Group("/") - management.Use(middleware.RequireManagementAccess()) // line 373-374 — passthrough-only reject - securityAdmin := management.Group("/security") - securityAdmin.Use(middleware.RequireRole(models.RoleAdmin)) // line 796-797 — CORRECT admin gate (template) - adminEncryption := management.Group("/admin/encryption") // line 546 — no RequireRole, BUT every handler calls isAdmin(c) - adminPlugins := management.Group("/admin/plugins") // line 560 — no RequireRole AND plugin_handler has NO admin check ← BUG (Part B) - crowdsecHandler.RegisterRoutes(management) // line 838 — no RequireRole AND crowdsec_handler has NO admin check ← ADVISORY (Part A) - … ~20 other *.RegisterRoutes(management) / inline management.* … -RegisterImportHandler(…) { // separate func, line 1005 - authenticatedAdmin := api.Group("/") - authenticatedAdmin.Use(AuthMiddleware(authService), RequireRole(models.RoleAdmin)) // line 1011-1012 — CORRECT admin gate (2nd template / name precedent) -} -``` - -Verified line numbers (grep, `development` HEAD): `auth/register` route `:295`, -`management := protected.Group("/")` `:373`, `adminPlugins` `:560`, -`securityAdmin` `:796`, `crowdsecHandler.RegisterRoutes(management)` `:838`. - -#### In-handler admin-check audit (grep `requireAdmin(|isAdmin(|RoleAdmin|GetString("role")|rejectPassthrough`, non-test) - -| Handler | In-handler role refs | Mounted on | Effective guard for `role=user` | -|---|---|---|---| -| `crowdsec_handler.go` | **0** | `management` (bare) | **NONE — vulnerable** (advisory) | -| `plugin_handler.go` | **0** | `management.Group("/admin/plugins")` (bare) | **NONE — mutations vulnerable** (Part B) | -| `encryption_handler.go` | 4 (`isAdmin`) | `management.Group("/admin/encryption")` (bare) | OK (in-handler) | -| `docker_handler.go` | 0 | `management` | none — read-only (`GET /docker/containers`, used by proxy-host create) | -| `proxy_host_handler.go` / `proxy_group_handler.go` | 0 | `management` | none — intended `role=user` capability | -| `remote_server_handler.go` | 0 | `management` | none | -| `security_headers_handler.go` | 0 | `management.Group("/security/headers")` | none | -| `hecate_handler.go` / `orthrus_handler.go` | 0 | `management` | none | -| `manual_challenge_handler.go` | 0 | `management` (`/dns-providers/:id/...`) | none | -| `settings_handler.go` | 8 | `management` + one `RequireRole` arg on `GET /settings/smtp` (`:457`) | partial in-handler | -| `system_permissions_handler.go` | 3 | `management` | in-handler | -| `certificate_handler.go`, `access_list_handler.go`, `domain_handler.go`, `uptime_handler.go`, `stats_handler.go`, `feature_flags_handler.go`, `audit_log_handler.go` | 0 | `management` | none — per-route verdict in §3.2 | -| `notification_provider_handler.go` | 3 (`requireAdmin` — `Create`/`Update`/`Delete` only; **`Test` & `Preview` are NOT guarded**) | `management` | mutations OK in-handler; `POST /notifications/providers/test` + `/preview` unguarded → see table #33b | -| `notification_template_handler.go` | 3 (`requireAdmin` — `Create`/`Update`/`Delete` only; **`Preview` NOT guarded**) | `management` | mutations OK in-handler; `POST /notifications/external-templates/preview` unguarded → see table #33b | -| `security_notifications.go` | 2 (`requireAdmin` — `GetSettings`/`UpdateSettings`) | `management` | OK (in-handler) | -| `notification_handler.go` (per-user inbox) | 0 | `management` | none — USER-OK (list / mark-read) | -| `security_handler.go` | many (`requireAdmin`) | reads on `management`, writes on `securityAdmin` | OK | -| `backup_handler.go` / `backup_remote_handler.go` | many (`requireAdmin`) | `management` | OK (in-handler) | -| `user_handler.go` | many (`requireAdmin` / `rejectPassthrough`) | `management` | OK (in-handler; `UpdateUser` deliberately allows non-admin self-service) | - -**Conclusion:** `management` is a de-facto "any authenticated non-passthrough -user" group; admin enforcement is applied inconsistently by three mechanisms -(dedicated subgroup, per-route middleware arg, in-handler `requireAdmin`). Two -areas — CrowdSec (all) and Plugins (mutations) — have **no** enforcement. - -#### Frontend route/nav gating — `frontend/src/App.tsx`, `frontend/src/components/Layout.tsx` - -- The SPA has **almost no role gating**. `App.tsx` wraps only: - - `/settings/*` in `` - - `/settings/users` in `` - - Everything else under `/` (`/security/*`, `/access-lists`, `/dns/*`, - `/hecate/*`, `/certificates`, `/security/audit-logs`, `/security/crowdsec`, - …) is reachable by any authenticated non-passthrough user, incl. `role=user`. -- `Layout.tsx` nav: only the **"Users"** entry is `role === 'admin'`-gated - (`:127`); passthrough sees no nav (`:151`); `uptime` / `cerberus` sections are - feature-flag gated. So a `role=user` today sees and can open CrowdSec config, - Access Lists, Security Headers, DNS providers, Certificates, Hecate, Audit - Logs, etc., and those pages call their APIs successfully because - `management` doesn't stop them. -- `RequireRole` component: `frontend/src/components/RequireRole.tsx` — renders - children if `user.role ∈ allowed`, else redirects. Ready to reuse. -- Public routes: `/login`, `/setup`, `/accept-invite` only. **No signup/register - page or route exists.** `grep "auth/register"` in `frontend/src` → 0 hits. - The endpoint is unused by the UI. - -**Implication for Part B (Q7 ruling):** because non-admin screens currently -consume many of these read endpoints, moving a *read/list* endpoint to -admin-only would regress a `role=user` page. The classification in §3.2 is -therefore **mutation-oriented**: reads/lists that back a `role=user`-reachable -page stay on `management`; mutations move behind `RequireRole(admin)` (via a -per-route arg, or a `RegisterRoutes(read, admin)` split where the handler -registers its own routes). Only **CrowdSec** (forced by Part A — no -`role=user` read need) moves *wholesale* to `managementAdmin`. Hecate, Orthrus -and Remote Servers each keep a small set of `GET` reads on `management` -(consumed by the proxy-host create/edit flow and the Dashboard) and move only -their mutations — verified against `frontend/src` (§3.2.1 C1/C2). Where a page -becomes admin-only in practice (CrowdSec, Audit Logs, the Orthrus -agent-management page, Encryption) a **companion frontend `RequireRole` guard + -nav filter** is added (mirroring the existing "Users" pattern) so `role=user` -never lands on a 403-ing page. - -#### `/auth/register` and `/setup` — how the first admin is created - -- `authHandler.Register` — `backend/internal/api/handlers/auth_handler.go:244`; - `RegisterRequest{Email,Password,Name}` (`min=8` password) at `:238`. Calls - `h.authService.Register(req.Email, req.Password, req.Name)` at `:251`, returns - `201` + user JSON. **No gating of any kind.** -- `authService.Register(email, password, name)` — - `backend/internal/services/auth_service.go:31`: `count == 0` ⇒ `RoleAdmin`, - else `RoleUser`. No toggle / invite / flag. -- **`POST /setup` does NOT call `authService.Register`.** - `UserHandler.Setup` (`backend/internal/api/handlers/user_handler.go:141`) - builds `models.User{Role: models.RoleAdmin, …}` directly and `tx.Create(&user)` - inside its own transaction (also writes `caddy.acme_email`). It is fully - independent of `authService.Register` / `authHandler.Register`. -- **`authService.Register` is NOT dead after removing the route.** grep - `\.Register(` (non-`metrics`/`tracker`/`dnsprovider`) — it is called from - ~28 test sites as a user-creation helper: - - `backend/internal/services/auth_service_test.go` (16 calls — incl. the - `count==0 → RoleAdmin` behavior test, `TestAuthService_Register*`) - - `backend/internal/api/middleware/auth_test.go` (12 calls) - - `backend/internal/api/handlers/user_integration_test.go:52` -- **`authHandler.Register` (HTTP handler) references:** only - `routes.go:295` (the route) and - `backend/internal/api/handlers/additional_coverage_test.go:729` - (`TestAuthHandler_Register_InvalidJSON` — a 400-on-bad-JSON coverage test). -- **Test / inventory references to the route path** (`grep "auth/register"`): - - `backend/internal/api/routes/routes_test.go:162` — `expectedRoutes` list in - `TestRegister_RoutesRegistration` - - `backend/internal/api/routes/routes_test.go:215` — `publicMutationAllowlist` - in `TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist` - - `backend/internal/api/routes/routes_test.go:335` — - `assert.Contains(t, routeMap, "/api/v1/auth/register")` in - `TestRegister_AllRoutesRegistered` - - `backend/integration/crowdsec_lapi_integration_test.go:59` — `authenticate()` - helper POSTs `/api/v1/auth/register` (errors ignored) to bootstrap a test - user; build-tagged integration test, not in default CI. - -⇒ **Part C deletions are exactly:** the route (`routes.go:295`), -`AuthHandler.Register` (`auth_handler.go:244-256`), `RegisterRequest` -(`auth_handler.go:238-242`). **Keep** `AuthService.Register` (+ its -`count==0 → RoleAdmin` logic) — still referenced by ~28 test call sites as a -helper. Update the 4 test references above. - -#### Existing email-invite flow (unchanged — the supported post-bootstrap path) - -- `models.User` fields (`backend/internal/models/user.go`): `InviteToken` - (`json:"-"`, `gorm:"index"`), `InviteExpires`, `InvitedAt`, `InvitedBy`, - `InviteStatus` (`"pending"|"accepted"|"expired"`); helper - `User.HasPendingInvite()`. -- `UserHandler.InviteUser` (`POST /users/invite`, admin — `requireAdmin`), - `ResendInvite` (`POST /users/:id/resend-invite`), `PreviewInviteURL`, - `ValidateInvite` (`GET /invite/validate`, public), `AcceptInvite` - (`POST /invite/accept`, public). `generateSecureToken()` at - `user_handler.go:494` (`crypto/rand` 32B → hex). -- Frontend: `frontend/src/pages/AcceptInvite.tsx` (route `/accept-invite`, reads - `?token=`), `frontend/src/api/users.ts` - (`inviteUser`/`validateInvite`/`acceptInvite`/`resendInvite`/`previewInviteURL`), - `frontend/src/pages/UsersPage.tsx` (`/settings/users`, admin-gated). - -#### AutoMigrate - -`backend/internal/api/routes/routes.go:112` — single `db.AutoMigrate(&models.X{}, …)` -call. **No new models in this feature**, so no change here. - -#### Test patterns - -- `backend/internal/api/routes/routes_test.go`: - - `TestRegister_AllRoutesRegistered` (`:310`) — asserts `routeMap` contains - `/api/v1/admin/crowdsec/*` and `/api/v1/auth/register`. - - `TestRegister_AdminRoutes` (`:481`) — GET admin paths expecting `401` - unauthenticated. - - `TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist` - (`:196`) — iterates every mutating `/api/v1/*` route, asserts `401|403` - unless in `publicMutationAllowlist`. **This is the Part B harness** — extend - it with a `role=user` dimension and remove the `auth/register` allowlist - entry. -- E2E: `tests/security-enforcement/authorization-rbac.spec.ts` & - `auth-api-enforcement.spec.ts` — `loginAndGetToken(context, {email,password})` - vs `TEST_USERS.admin` / `TEST_USERS.user`; assert `role=user` → `403` on - privileged routes. Playwright projects: `security-tests` (CI shard), - `firefox` (local DoD, single browser). - -### 2.2 Docs to update - -| Doc | Why | -|---|---| -| `ARCHITECTURE.md` → "Security Architecture" / "Authentication & Authorization" | New `managementAdmin` authorization boundary; public registration removed; bootstrap-via-`/setup` + email-invite is the account-creation model. | -| `SECURITY.md` → "Authentication & Authorization" (~line 1148) | RBAC description: explicit admin-subgroup enforcement; no public self-registration. | -| `docs/security.md`, `docs/features/access-control.md` | User-facing: how accounts are created (first-run setup + admin invites), admin-only security surfaces. | -| `docs/features.md` | One-line touch if wording references self-registration. | -| `docs/features/crowdsec.md`, `docs/features/custom-plugins.md` / `plugin-security.md` | Note admin-only requirement (behavior clarification). | - -### 2.3 External dependencies - -None new. Stdlib + existing libs only. +### 2.1 `go_notify_yourself` v0.3.0 — `providers/webpush` + +Verified directly against the local clone (`/projects/go_notify_yourself`, +tag `v0.3.0`): + +- **`webpush.Config`** (`providers/webpush/webpush.go`) mixes two field + groups in one struct, confirmed by the package doc comment: + - VAPID **application identity** (shared across every subscriber): + `VAPIDPublicKey`, `VAPIDPrivateKey`, `VAPIDSubject` (all + base64url-no-padding strings; `VAPIDSubject` must be `mailto:` or + `https:` prefixed). + - One subscriber's **destination** (per browser/device): + `Endpoint`, `P256dh`, `Auth` — the three fields of a browser + `PushSubscription`. + - Delivery hints: `TTL` (int, seconds; 0 → `DefaultTTL` = 4 weeks), + `Urgency` (`"very-low"|"low"|"normal"|"high"`, optional), `Topic` + (≤32 URL-safe base64 chars, optional). + - Payload templating: `Template`/`CustomTemplate` — same + minimal/detailed/custom convention as every other JSON-payload + provider (`providers/internal/render`). +- **`webpush.Client`** (`New(cfg Config, w *transport.Wrapper) *Client`) + implements `notify.Sender` (`Send(ctx, Message) error`) **unchanged** — + confirmed via `var _ notify.Sender = (*Client)(nil)` in `webpush.go`. The + package doc comment states the intended fan-out pattern explicitly: *"a + host application fanning a Message out to many subscribers constructs one + `*Client` per subscription (cheap: New does no I/O) and calls Send on + each, exactly like fanning out to many Sender values of any other + provider type."* This resolves the open design question from project + memory — no new interface shape is needed. +- **`webpush.GenerateVAPIDKeyPair() (publicKey, privateKey string, err error)`** + (`providers/webpush/vapid.go`) generates a P-256 keypair, base64url + (no padding) encoded, matching `Config.VAPIDPublicKey`/`VAPIDPrivateKey`. + Its doc comment is explicit: *"Intended to be called once at application + setup time... every existing PushSubscription is bound to the exact + public key it was created with... rotating this keypair invalidates every + existing subscription."* — this is the authoritative confirmation that + VAPID identity is app-wide and effectively-immutable-in-practice, driving + the singleton design in §3.2. +- **Registration** (`providers/webpush/register.go`) follows the identical + `init()` → `notify.Register("webpush", factory)` pattern as every other + provider (compared directly against `providers/pushover/register.go`). + Expected config keys, read from the factory: `transport` (required, + `*transport.Wrapper`), `vapid_public_key`, `vapid_private_key`, + `vapid_subject`, `endpoint`, `p256dh`, `auth` (all required strings), + `ttl` (optional int), `urgency`, `topic`, `template`, `custom_template` + (optional strings). Registered name is `"webpush"` (lowercase, no + underscore) — `docs/INTEGRATION.md` §3.6 in the module repo calls this + out explicitly as a naming convention every provider must follow. +- **Dead-subscription signal — important gap found in research, not in the + task brief's assumptions:** `transport.Wrapper.Send` + (`transport/wrapper.go` line ~223) returns errors for non-2xx responses + as a **plain formatted string**: + `fmt.Errorf("provider returned status %d: %s", resp.StatusCode, hint)` — + there is **no typed/sentinel error** (no `StatusError` type, no + `errors.Is`-compatible marker) anywhere in `transport/` or `webpush/`. + Detecting a 404/410 "subscription gone" signal (the standard Web Push + convention for "prune this subscription") therefore requires parsing the + numeric status code out of that formatted string on the Charon side — + this is called out explicitly as a design risk in §7 and a required + implementation detail in §3.5, since it was not something the task brief + could confirm without reading `transport/wrapper.go` directly. +- `docs/INTEGRATION.md` (module repo) §"webpush" gives a worked example: + `sender := webpush.New(webpush.Config{...}, wrapper)`, looping + `for host, sub := range subscribers`, logging (not swallowing) each + `Send` error independently — confirming per-subscription error isolation + is the intended fan-out contract, not "abort on first failure." + +### 2.2 Charon's current notification-provider architecture + +- **`models.NotificationProvider`** (`backend/internal/models/notification_provider.go`) + is a flat GORM row = one destination. `Type` discriminates row meaning; + `URL`/`Token` are repurposed per type (see mapping table below). Also + carries `ServiceConfig string` — **a JSON-blob column already present on + the model, tagged `// JSON blob for typed service config`, and currently + unused by any provider type** (`grep` across `internal/` found zero other + references). This is the designed escape hatch for a provider whose + config doesn't fit the URL/Token shape — see §3.2 for why Web Push uses + it instead of adding new columns. +- Per-type field mapping (`notify_provider_adapter.go` + `providerConfigMap`, read directly from source, not inferred): + + | Type | `URL` column | `Token` column (never exposed, `json:"-"`) | + |---|---|---| + | discord | webhook URL | — | + | slack | (unused placeholder) | webhook URL | + | gotify | server URL | API token | + | pushover | user key | API token | + | ntfy | topic URL | auth token | + | telegram | chat ID | bot token | + | webhook/generic | target URL | — | + +- **Dispatch fan-out today is one row = one `notify.Sender` = one goroutine** + (`notification_service.go` `SendExternal`, confirmed at the `for _, + provider := range providers { ... go s.dispatchViaNotify(...) }` loop — + each provider row gets exactly one `buildNotifySender` call and one + `Send`). Web Push breaks this 1:1 assumption; §3.5 defines the new + fan-out shape. +- **Allowlist gates that must be extended for `webpush`** (all confirmed by + direct read, not assumed from the task brief): + - `notify_providers_import.go` — blank-import list; comment explicitly + states it is kept in sync by hand with + `isSupportedNotificationProviderType`, guarded by + `notification_service_registry_consistency_test.go` + (`TestSupportedProviderAllowlistIsSubsetOfRegisteredTypes`), which + **must** gain `"webpush"` in its literal `supportedTypes` slice. + - `notification_service.go`: + - `isSupportedNotificationProviderType` (line ~136) — add `"webpush"`. + - `isDispatchEnabled` (line ~145) — add a `"webpush"` case reading a + new `FlagWebPushServiceEnabled` flag. + - `supportsJSONTemplates` (line ~127) — **decision: add `"webpush"`**. + Web Push payload is JSON (encrypted client-side by the module, but + the plaintext the admin/Charon controls via `Template`/ + `CustomTemplate` is JSON, exactly like every other + `supportsJSONTemplates` type) — confirmed by `webpush.Config`'s + `Template string` field using the identical + `providers/internal/render` convention. + - `SendExternal` (line ~212) — the per-event-type `shouldSend` switch + needs no changes (it already switches on `eventType`, not provider + type); the dispatch loop needs a new branch for `webpush` (see §3.5) + analogous to the existing `email` special-case branch + (`dispatchEmailViaNotify`), since webpush also cannot go through the + generic single-`Sender`-per-row `dispatchViaNotify` unmodified. + - `notification_feature_flags.go` — add + `FlagWebPushServiceEnabled = "feature.notifications.service.webpush.enabled"`. + - `notify_provider_adapter.go` — `providerConfigMap`'s switch does **not** + gain a `webpush` case (Web Push's per-subscription config is built + per-subscription in the new dispatch path, not via the generic + single-row `buildNotifySender` — see §3.5). `resolveTemplateFields` is + reused unchanged (webpush respects the same + minimal/detailed/custom + legacy-detailed-template translation as every + other JSON provider). + - `notify_client_adapter.go` — **no changes needed.** The shared + `*transport.Wrapper` (`NewNotifyTransportWrapper`) is provider-agnostic; + its `notifyURLValidator` wraps `security.ValidateExternalURL`, confirmed + by reading `internal/security/url_validator.go` to have **no + provider-specific host allowlist** — only scheme (`https` required + outside dev), hostname format, and private-IP/localhost blocking. This + matters because Web Push endpoints are on arbitrary, unpredictable push + -service hosts (`fcm.googleapis.com`, `updates.push.services.mozilla.com`, + `*.notify.windows.com`, etc., varying per browser vendor) — unlike + Discord's fixed-host validation, no new host allowlist is needed or + possible to maintain. +- **Auth model**: `backend/internal/api/routes/routes.go` line ~372-373: + `management := protected.Group("/"); management.Use(middleware.RequireManagementAccess())` + — every existing notification-provider route + (`/notifications/providers*`) sits under this authenticated group. The + new VAPID-public-key and subscription endpoints will sit under the same + group (§3.6) — these are **not** anonymous/public endpoints; Web Push in + Charon is "the logged-in admin's own browser opts in to receiving this + instance's alerts," not a public subscription surface. +- **`c.Get("userID")`** is the established convention + (`internal/api/middleware/auth.go`, confirmed via grep across + `internal/api/middleware/*_test.go`) for retrieving the authenticated + user inside a handler — used to scope a `WebPushSubscription` row to the + user who created it (§3.2), enabling per-user unsubscribe-my-own-device + semantics without a new authorization concept. +- **Migration registration**: `internal/api/routes/routes.go` + `db.AutoMigrate(...)` (line ~112) lists every persistent model + explicitly, most recently `models.BackupJob{}`. The new + `models.WebPushSubscription{}` must be added here (models are listed in + FK-dependency order — `WebPushSubscription` has an FK to + `NotificationProvider`, so it can be added anywhere after that model, + which is already present at line ~125). +- **Singleton-row precedent**: `models.SecurityConfig` + (`internal/models/security_config.go`) is Charon's existing "one global + config row" pattern — single table, one seeded row + (`models.SeedDefaultSecurityConfig`, called unconditionally on every + startup in `routes.go`), sensitive field excluded from JSON + (`BreakGlassHash string json:"-"`). This is the direct precedent for how + Web Push's VAPID private key should never leave the backend (§3.2) — + reusing `NotificationProvider.Token`'s existing `json:"-"` contract + rather than inventing a new pattern. +- **No existing PWA/service-worker infrastructure** in `frontend/` + (confirmed: no `sw.js`, no `vite-plugin-pwa`, no `workbox` reference + anywhere in `frontend/`). The service worker file and its registration + are a greenfield addition (§3.7). +- **Frontend provider-type list** + (`frontend/src/api/notifications.ts` line 3): + `SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', + 'email', 'telegram', 'slack', 'pushover', 'ntfy']` — needs `'webpush'` + appended, plus a `SupportedNotificationProviderType` type-narrowing + update, mirrored in `Notifications.tsx`'s `isSupportedProviderType`/ + `normalizeProviderType` helpers (both derive from the same const, so no + separate list to maintain there). --- ## 3. Technical Specifications -### 3.1 Part A — Close the authorization hole (advisory fix) - -#### 3.1.1 Structural change in `routes.go` - -Declare one admin subgroup on `management`, immediately after `management` is -created (`routes.go:373-374`), named for consistency with the existing -`securityAdmin` / `authenticatedAdmin`: +### 3.1 The one-to-many resolution (central design decision) + +**Decision: Option (a) from the task brief — a single `NotificationProvider` +row (`Type = "webpush"`) holds the VAPID application identity, and a new +child table `WebPushSubscription` holds each browser's destination, +FK'd to that provider row.** + +Rejected alternative (Option b, "some other shape" — e.g. a fully separate +top-level model/dispatch path decoupled from `NotificationProvider`): +rejected because it would require duplicating every piece of +`NotificationProvider`-keyed machinery Web Push still legitimately needs — +`Enabled` toggle, the six `NotifyXxx` per-event-type preference booleans, +`Name`, the `SendExternal` dispatch-loop membership, the +`isDispatchEnabled`/feature-flag gate, and the provider list/delete UI +pattern. None of that is Web-Push-specific; only the "one row, N +destinations" shape is. Keeping Web Push as a `NotificationProvider` row +lets 90% of the existing dispatch/preferences/allowlist machinery apply +unchanged, isolating the actually-novel part (fan-out over subscriptions) +to one new function (§3.5). + +**Singleton constraint**: exactly one `Type = "webpush"` row may exist at a +time. This mirrors `GenerateVAPIDKeyPair`'s own documented invariant (§2.1): +rotating the VAPID keypair invalidates every existing subscription, so +"multiple Web Push provider rows" would either mean multiple independent +VAPID identities (which the UI has no reason to expose — there is exactly +one Charon instance and one set of admin browsers) or be nonsensical +duplicate identities. No other provider type has this constraint today; +this is a deliberate, documented deviation from the generic +create-any-number-of-rows pattern, called out explicitly rather than +silently special-cased. + +**Enforcement (revised per Supervisor review — DB-level, not service-layer +alone)**: an earlier version of this spec enforced the singleton purely as +a service-layer pre-check in `NotificationService.CreateProvider` +(`SELECT COUNT(*) FROM notification_providers WHERE type = 'webpush'` +before `INSERT`, rejecting with 409 if count > 0). **Supervisor traced this +and confirmed it is not atomic**: two concurrent provisioning requests can +each run the `COUNT` and both observe `0` before either commits its +`INSERT`, because SQLite's `sqlDB.SetMaxOpenConns(1)` in +`backend/internal/database/database.go:144` only serializes individual +statements through the single connection — it does not make the +`COUNT`-then-`INSERT` *sequence* atomic across two separate request +goroutines interleaving their statements on that one connection. The +result is two independent `webpush` provider rows with two independent +VAPID identities, silently breaking the invariant this whole section +argues for. This is the same class of check-then-act race already fixed +elsewhere in this codebase for `UptimeHost` creation (GitHub issue #1221, +see `ensureUptimeHost` in `backend/internal/services/uptime_service.go:408-416`), +which resolved it with a DB-level unique index plus `clause.OnConflict`. + +The fix here (see §3.3.4 for the exact migration, §3.4.1 for the resulting +API error shape): a **DB-level partial unique index**, +`CREATE UNIQUE INDEX idx_webpush_singleton ON notification_providers(type) +WHERE type = 'webpush'` (SQLite supports partial indexes), makes the +second concurrent `INSERT` fail at the database regardless of what either +caller's `COUNT` observed — this is the actual enforcement mechanism, and +it is safe under concurrent writers by construction (unlike the +count-then-insert sequence). Unlike `ensureUptimeHost`'s +`OnConflict{DoNothing}`-then-refetch (appropriate there because a second +caller wanting "the host row" is happy to receive the winner's row), a +second `webpush` provisioning attempt is treated as a genuine conflict the +caller should see and react to (they may not realize a provider already +exists), so `CreateProvider` catches the resulting constraint-violation +error and maps it to `409`, using this codebase's existing +detection idiom (`errors.Is(err, gorm.ErrDuplicatedKey) || +strings.Contains(err.Error(), "UNIQUE constraint failed")`, already used +in `backend/internal/api/handlers/custom_theme_handler.go:71,121` and +`backend/internal/services/crowdsec_whitelist_service.go:67`) rather than +introducing a new error-detection pattern. The service-layer `COUNT` check +is retained as a cheap, non-authoritative fast-path (returns a clear 409 +without waiting on a constraint-violation round trip in the common, +uncontended case) — but the index is what actually guarantees the +invariant, and the 409-on-constraint-violation path is what makes that +guarantee visible to the loser of a race instead of surfacing as an +unhandled 500. + +### 3.2 VAPID identity storage + +- **`VAPIDPrivateKey` → `NotificationProvider.Token`** (existing column, + already `json:"-"`, already the established "never expose this" contract + used by gotify/pushover/ntfy/telegram tokens and Slack's webhook URL). + No schema change. +- **`VAPIDPublicKey` and `VAPIDSubject` → `NotificationProvider.ServiceConfig`** + (existing unused JSON-blob column), as: + ```json + {"vapid_public_key": "BN...", "vapid_subject": "mailto:admin@example.com"} + ``` + Both values are safe to expose in the provider-list API response (the + public key is, by construction, public; the subject is an + operator-supplied contact URI already visible in the Web Push form) — + unlike `Token`, `ServiceConfig` is not `json:"-"`, which is intentional: + the frontend needs `vapid_public_key` to call + `PushManager.subscribe({applicationServerKey: ...})` and it is served + from the provider row the admin already fetches. (The dedicated + `GET /notifications/providers/webpush/vapid-public-key` endpoint in §3.6 + exists for the *subscribing browser's* convenience/caching, not because + the key is sensitive.) +- **Provisioning**: auto-generated on first use, no manual key entry. + `POST /notifications/providers/webpush/provision` (§3.6) calls + `webpush.GenerateVAPIDKeyPair()`, requires the admin to supply only + `name` and `vapid_subject` (validated server-side: must start with + `mailto:` or `https://`, per `webpush.Client.Send`'s own runtime check — + duplicating that validation client- and server-side avoids a + provision-succeeds-but-every-send-fails footgun), then creates the + singleton `NotificationProvider` row. This is a **dedicated endpoint**, + not the generic `POST /notifications/providers` create form — the + generic form's `URL`/`Token` text inputs don't apply to Web Push (no + webhook URL to paste), and key generation is a server-side action, not + client-submitted config. `Notifications.tsx`'s existing per-type + conditional-fields pattern (see `isGotify`/`isTelegram`/... constants at + lines 147-152) already renders a different field set per `type`, so + Web Push's "Provision" button replacing the URL/Token fields is + consistent with that existing per-type branching, not a new UI paradigm. + **Race-safety** (see §3.1 "Enforcement" and §3.3.4): the handler behind + this endpoint calls `NotificationService.CreateProvider`, which after + its cheap `COUNT`-based fast-path check still relies on the DB-level + partial unique index as the actual source of truth. If the `INSERT` + fails with a unique-constraint violation on `idx_webpush_singleton` + (i.e., a concurrent request won the race), `CreateProvider` returns a + sentinel error that the handler maps to the same `409` as the fast-path + case (§3.4.1) — the caller cannot distinguish "lost a race" from + "checked after someone else already provisioned," which is correct, + since both are the same user-facing fact ("a Web Push provider already + exists"). + +### 3.3 Database schema + +#### 3.3.1 `NotificationProvider` (existing table — no column additions) + +Reused as-is: `Type = "webpush"`, `Token` = VAPID private key, +`ServiceConfig` = `{"vapid_public_key","vapid_subject"}` JSON, `Name`, +`Enabled`, and the existing six `NotifyXxx` booleans all apply unchanged. +`URL` is left empty for this type (matching Slack's existing +"unused placeholder" pattern for a type whose real destination lives +elsewhere). + +#### 3.3.2 New table: `WebPushSubscription` + +New file `backend/internal/models/webpush_subscription.go`: ```go -// management: any authenticated non-passthrough user (RequireManagementAccess). -management := protected.Group("/") -management.Use(middleware.RequireManagementAccess()) - -// managementAdmin: management routes that mutate or expose privileged -// infrastructure. Deny-by-default for role=user. Mirrors securityAdmin -// (routes.go ~§"Security module enable/disable") and authenticatedAdmin -// (RegisterImportHandler). Enforcement is the ONLY guard on these routes — -// no redundant in-handler requireAdmin (see spec §3.2 Q6 ruling). -managementAdmin := management.Group("/") -managementAdmin.Use(middleware.RequireRole(models.RoleAdmin)) +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// WebPushSubscription is one browser/device's Web Push destination, +// created when an authenticated Charon user's browser completes +// PushManager.subscribe() and POSTs the resulting PushSubscription to the +// backend. Each row is fanned out to individually by +// NotificationService.dispatchWebPushViaNotify (one webpush.Client per +// row), all sharing the parent NotificationProvider's VAPID identity. +type WebPushSubscription struct { + ID string `gorm:"primaryKey" json:"id"` + ProviderID string `gorm:"index;not null" json:"provider_id"` // FK -> NotificationProvider.ID (Type="webpush") + UserID string `gorm:"index;not null" json:"user_id"` // FK -> User.ID; owner, for scoped unsubscribe + + // PushSubscription destination (from the browser's PushSubscription + // object; see webpush.Config's matching field doc comments). + Endpoint string `gorm:"uniqueIndex;type:text;not null" json:"endpoint"` + P256dh string `gorm:"type:text;not null" json:"-"` // subscriber DH public key; not attacker-sensitive but never needed client-side after registration + Auth string `gorm:"type:text;not null" json:"-"` // subscriber auth secret; same rationale + + // Display/diagnostic metadata, not used for dispatch. + UserAgent string `json:"user_agent,omitempty" gorm:"type:text"` + + // Pruning bookkeeping (§3.5). + LastSeenAt time.Time `json:"last_seen_at"` // updated on successful send or (re)registration + LastFailureAt *time.Time `json:"last_failure_at,omitempty"` + FailureCount int `json:"failure_count" gorm:"default:0"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *WebPushSubscription) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == "" { + s.ID = uuid.New().String() + } + return +} ``` -Change `routes.go:838`: +Design notes: +- `Endpoint` is `uniqueIndex` — the same browser subscribing twice (e.g. + re-subscribing after clearing site data produces the same or a new + endpoint depending on browser; if identical, `POST .../subscribe` is + idempotent via upsert-on-conflict, see §3.6) must not create duplicate + rows that both receive the same push. +- `P256dh`/`Auth` are `json:"-"` — while not bearer-token-equivalent secrets + the way `NotificationProvider.Token` is, they are per-subscriber + encryption material with no legitimate reason to round-trip back to any + frontend after registration (the frontend already has them locally from + `PushManager.subscribe()`); withholding them is defense-in-depth + consistent with the project's "never expose what the client doesn't need + back" convention. +- `FailureCount`/`LastFailureAt` back a **soft-delete-after-N-failures** + policy rather than instant deletion on the first non-410 failure (a + transient 5xx from the push service should not nuke a subscription) — + see §3.5 for the exact pruning rule. + +#### 3.3.3 Migration registration + +`backend/internal/api/routes/routes.go`, `db.AutoMigrate(...)` block +(§2.2): add `&models.WebPushSubscription{}` immediately after +`&models.NotificationProvider{}` (FK dependency ordering — GORM's +auto-migrate doesn't strictly require FK-target-first ordering for SQLite, +but the file's existing comments show this codebase's convention of +ordering by FK dependency, e.g. `ProxyGroup{}` before `ProxyHost{}`). ```go -crowdsecHandler.RegisterRoutes(management) → crowdsecHandler.RegisterRoutes(managementAdmin) +&models.NotificationProvider{}, +&models.WebPushSubscription{}, // Web Push subscriptions — FK to NotificationProvider (Type="webpush") +&models.NotificationTemplate{}, ``` -`CrowdsecHandler.RegisterRoutes` is unchanged (it already prefixes every route -with `/admin/crowdsec/…`). The full path set is identical; only the middleware -chain gains `RequireRole(admin)`. **No in-handler `requireAdmin` is added to -`crowdsec_handler.go`** (Q6 ruling — subgroup-only, matching `securityAdmin`). - -#### 3.1.2 Companion frontend guard (prevents a `role=user` dead page) - -`role=user` can currently open `/security/crowdsec` (`CrowdSecConfig` page) and -its nav entry, which after Part A would 403 on every call. Add, in the same PR: +#### 3.3.4 Singleton enforcement: partial unique index (race-condition fix) -- `frontend/src/App.tsx` — wrap the `security/crowdsec` route element in - `` (like `/settings/users`). -- `frontend/src/components/Layout.tsx` — gate the `navigation.crowdsec` child - entry (`:112`) with `user?.role === 'admin'` (spread-in pattern, same as - `:127` "Users"). +**Added per Supervisor review** — see §3.1 "Enforcement" for the full +rationale. GORM struct tags (`gorm:"uniqueIndex"`) cannot express a +*partial* (`WHERE`-qualified) index, so this cannot be expressed as a +`WebPushSubscription`/`NotificationProvider` struct tag; it is created via +a raw, idempotent `db.Exec` immediately after the `AutoMigrate(...)` call +in `backend/internal/api/routes/routes.go`, following the same +post-AutoMigrate idempotent-migration-step pattern already used there for +`migrateViewerToPassthrough` (`routes.go:64-69`, called at `routes.go:151`): -#### 3.1.3 Error contract - -`RequireRole(models.RoleAdmin)` already returns `401 {"error":"Unauthorized"}` -(no role) / `403 {"error":"Forbidden"}` (`role=user`/`passthrough`). No -middleware change. Matches the reporter PoC's expectation of a hard `403` for a -non-admin token. - -#### 3.1.4 Regression tests — `backend/internal/api/routes/routes_test.go` (+ handler test) - -New `TestRegister_CrowdsecAdminRoutesRequireAdminRole`: +```go +// Enforce the Web Push provider singleton invariant at the database +// level — a service-layer COUNT-then-INSERT check alone is not atomic +// under concurrent requests (see docs/plans/current_spec.md §3.1). +// IF NOT EXISTS makes this idempotent across restarts, matching every +// other startup migration step in this function. +if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_webpush_singleton + ON notification_providers(type) WHERE type = 'webpush'`).Error; err != nil { + return uptimeShutdown, fmt.Errorf("create webpush singleton index: %w", err) +} +``` -| Case | Token role | Route | Expected | +Placement: after the main `AutoMigrate(...)` block (the `notification_providers` +table must exist first) and before any code path that could call +`NotificationService.CreateProvider` — i.e., before `Register`/`RegisterWithDeps` +finishes wiring routes. Failure to create the index fails startup loudly +(matching the existing `auto migrate: %w` error-return convention +immediately above it in the same function) rather than silently running +without the safety guarantee. + +**SQLite partial-index support**: confirmed available — SQLite has +supported partial indexes (the `WHERE` clause on `CREATE INDEX`) since +3.8.0 (2015); this codebase's SQLite driver/runtime is well past that +baseline (no version-gating needed). + +**Test coverage for this commit** (see §9 Commit Slicing Strategy, commit 3): +a concurrency test that fires two goroutines both calling +`NotificationService.CreateProvider` with `Type: "webpush"` against the +same `*gorm.DB` and asserts exactly one succeeds and the other's `Insert` +returns a unique-constraint-violation error — this is the regression test +for the exact race Supervisor identified, and it must fail against the +pre-fix (service-layer-`COUNT`-only) code to prove it actually exercises +the race rather than passing vacuously. + +#### 3.3.5 GORM Security Scan + +Per CLAUDE.md §1.5, this change touches `internal/models/**` and adds a +migration — `./scripts/scan-gorm-security.sh --check` is a **mandatory** +gate before this PR merges (see §8). + +### 3.4 API contracts + +All routes below are mounted on the existing authenticated `management` +group (`routes.go` line ~372: `management.Use(middleware.RequireManagementAccess())`), +matching every existing `/notifications/*` route. + +| Method | Path | Purpose | Auth | |---|---|---|---| -| Control (PoC parity) | none | `POST /api/v1/admin/crowdsec/stop` | `401` | -| Escalation blocked | `user` | `POST /api/v1/admin/crowdsec/stop` | `403` | -| Escalation blocked | `user` | `GET /api/v1/admin/crowdsec/bouncer/key` | `403` | -| Escalation blocked | `user` | `POST /api/v1/admin/crowdsec/ban` | `403` | -| Escalation blocked | `user` | `GET /api/v1/admin/crowdsec/file?path=…` | `403` | -| Admin unaffected | `admin` | `GET /api/v1/admin/crowdsec/status` | not `401` / not `403` | - -Harness: `Register(ctx, gin.New(), db, cfg)`; seed a `role=user` + a -`role=admin` user; mint JWTs via -`services.NewAuthService(db,cfg).GenerateToken(&user)`; send -`Authorization: Bearer …`. Reuse the in-memory sqlite + `cfg.JWTSecret` pattern -already in `routes_test.go`. - -### 3.2 Part B — Audit & structurally harden the `management` group - -#### 3.2.1 Rulings baked in - -- **Q6 — belt-and-braces:** subgroup-only. Do **not** add in-handler - `requireAdmin` to `crowdsec_handler.go` / `plugin_handler.go`. Match - `securityAdmin` / `authenticatedAdmin` exactly. -- **Q7 — reads that back non-admin screens stay on `management`.** The frontend - exposes nearly every management page to `role=user` (§2.1). So: - classification is **mutation vs. read**, not endpoint-group. A `GET`/`list` - that a `role=user`-reachable page calls is **READ (stays on `management`)**; - its `POST`/`PUT`/`PATCH`/`DELETE` siblings move behind - `RequireRole(admin)`. Where a whole capability is infra-admin **and no - `role=user`-reachable screen consumes any of its reads**, the group moves - wholesale **and** gets a companion `RequireRole` frontend guard + nav filter - (like Part A does for CrowdSec). -- **Q8 — least-invasive split mechanism.** For routes registered inline in - `routes.go`, add `middleware.RequireRole(models.RoleAdmin)` as a per-route - 2nd handler arg (exactly like the existing `routes.go:457` - `management.GET("/settings/smtp", middleware.RequireRole(models.RoleAdmin), …)`). - Where a handler's own `RegisterRoutes(rg)` registers a mix of read and - mutation routes and only the mutations move, change that handler's signature - to `RegisterRoutes(read, admin *gin.RouterGroup)` and register each route on - the correct group. - - **`HecateHandler`, `OrthrusHandler`, `RemoteServerHandler` — read/write - split, NOT wholesale move** (C1/C2). Verified: `GET /orthrus/agents` is - consumed by `frontend/src/components/hecate/ConnectionTypeSelector.tsx` - (`useAgentList`, rendered inside the `role=user`-reachable proxy-host - create/edit flow) and `GET /hecate/status` by - `frontend/src/api/hecate.ts` (imported by `Dashboard.tsx`, route `/`, all - roles). Reads that stay on `management`: - `GET /hecate/status`, `GET /hecate/tunnels`, `GET /hecate/tunnels/:uuid`, - `GET /orthrus/agents`, `GET /orthrus/agents/:uuid`, - `GET /remote-servers`, `GET /remote-servers/:uuid`. Everything else on those - three handlers (create/update/delete/start/stop/rotate-credentials/revoke/ - provision/patch/install-snippets/proxy-status/test/provider-device - lists+sync) → `managementAdmin`. Each handler's `RegisterRoutes` takes - `(read, admin *gin.RouterGroup)`. - - **`SecurityHeadersHandler` — inline in `routes.go`, per-route args, NOT a - bespoke signature** (C6). Its ~11 routes move out of - `h.RegisterRoutes(management)` into explicit - `management.GET/POST(...)` / `managementAdmin.POST/PUT/DELETE(...)` lines in - `routes.go` (its siblings — certificates, access-lists, domains, - feature-flags — are already registered inline this way). The - `SecurityHeadersHandler.RegisterRoutes` method is removed. - - **`CrowdsecHandler`** moves wholesale (Part A) — no `role=user` read need. - - **`PluginHandler`, DNS/credential/manual-challenge, certificate, - access-list, domain, settings, feature-flags, system-repair, notification - test/preview** routes are all inline in `routes.go` → per-route - `RequireRole(admin)` args. - -#### 3.2.2 Route classification table - -Verdicts: **MOVE-GROUP** = whole registration → `managementAdmin` + companion -frontend guard (CrowdSec only) · **MOVE → `managementAdmin`** = these specific -route(s) re-registered on `managementAdmin` (a read that, on review, no -`role=user` screen needs) · **ADMIN-ARG** = keep on `management`, add per-route -`RequireRole(admin)` to the mutations, reads stay (for handlers that register -their own routes, this is a `RegisterRoutes(read, admin)` split) · **READ -(stays)** = `GET`/list that a `role=user`-reachable page consumes, no change · -**USER-OK** = stays on `management`, no change (add to the enforcement-test -allowlist if it is a non-mutating `POST`) · **KEEP (in-handler)** = already -guarded inside the handler, leave mechanism, verify test. - -> The implementing engineer MUST re-run -> `grep -n "management\.\(GET\|POST\|PUT\|PATCH\|DELETE\)\|\.RegisterRoutes(management)" routes.go` -> against HEAD at implementation time and reconcile drift with this table in the -> PR description. - -| # | Route(s) | Handler | Current guard | Verdict | Action | -|---|---|---|---|---|---| -| 1 | `POST/GET/DELETE /admin/crowdsec/*` (~45) | `CrowdsecHandler` | none | **MOVE-GROUP** | Part A: `RegisterRoutes(managementAdmin)` + frontend guard on `/security/crowdsec`. | -| 2 | `GET /admin/plugins`, `GET /admin/plugins/:id` | `PluginHandler` | none | **READ (stays)** | `/dns/plugins` page (`role=user`-reachable) lists plugins. Keep on `management`. | -| 3 | `POST /admin/plugins/:id/enable`, `/:id/disable`, `/reload` | `PluginHandler` | none | **ADMIN-ARG** | Add `middleware.RequireRole(models.RoleAdmin)` to these 3 inline registrations (`routes.go:562-565`). This closes the confirmed 2nd live instance. | -| 4 | `GET/POST/PUT/DELETE /admin/encryption/*` | `EncryptionHandler` | in-handler `isAdmin(c)` | **KEEP (in-handler)** + also move the `adminEncryption` group decl to `managementAdmin.Group("/admin/encryption")` for defense-in-depth (no behavior change; removes the "silent 200 if the in-handler check is ever dropped" risk). Verify existing tests. | -| 5 | `GET /security/status`, `/config`, `/decisions`, `/rulesets`, `/rate-limit/presets`, `/geoip/status`, `/waf/exclusions` | `SecurityHandler` (reads) | `management` | **READ (stays)** — security-posture visibility; `Security` dashboard is `role=user`-reachable. Document. | -| 6 | `securityAdmin.*` (all `POST /security/*`, module enable/disable, PATCH) | `SecurityHandler` (writes) | `securityAdmin` = `RequireRole(admin)` | **KEEP** — already correct; the template for this work. | -| 7 | `GET /security/headers/profiles`, `/profiles/:id`, `/presets`; `POST /score`, `/csp/validate`, `/csp/build` | `SecurityHeadersHandler` | `management` (`/security/headers` subgroup) | **USER-OK** — reads + pure calculators (the 3 `POST`s do not persist). `SecurityHeaders` page is `role=user`-reachable. Inline these on `management.GET/POST(...)` in `routes.go`; add the 3 calculator `POST`s to the enforcement-test allowlist. | -| 8 | `POST/PUT/DELETE /security/headers/profiles`, `POST /security/headers/presets/apply` | `SecurityHeadersHandler` | `management` | **ADMIN-ARG** — inline on `managementAdmin.POST/PUT/DELETE(...)` in `routes.go` (C6 — per-route, no bespoke 2-group `RegisterRoutes` signature; delete the `SecurityHeadersHandler.RegisterRoutes` method — its siblings are already registered inline). | -| 9 | `GET/POST/PUT/DELETE /proxy-hosts*`, bulk-update-{acl,group,security-headers} | `ProxyHostHandler` | `management` | **USER-OK** — core `role=user` capability; per-host authz via `PermittedHosts` / forward-auth. No change. | -| 10 | `GET/POST/PUT/DELETE /proxy-groups*` | `ProxyGroupHandler` | `management` | **USER-OK** — same rationale. No change. | -| 11 | `GET /remote-servers`, `GET /remote-servers/:uuid` | `RemoteServerHandler` | `management` | **READ (stays)** — proxy-host create/edit references remote servers; `RemoteServers` page is `role=user`-reachable. | -| 12 | `POST/PUT/DELETE /remote-servers*`, `POST /remote-servers/test`, `POST /remote-servers/:uuid/test` | `RemoteServerHandler` | `management` | **ADMIN-ARG** (C2) — SSH targets + credentials. `RemoteServerHandler.RegisterRoutes(read, admin *gin.RouterGroup)`: the 2 `GET`s (row 11) on `read`, these 5 on `admin`. | -| 13 | `GET /docker/containers` | `DockerHandler` | `management` | **READ (stays)** — proxy-host create picks a container. Read-only. | -| 14a | `hecate/*` — reads: `GET /hecate/status`, `GET /hecate/tunnels`, `GET /hecate/tunnels/:uuid` | `HecateHandler` | `management` | **READ (stays)** (C1) — `GET /hecate/status` is consumed by `frontend/src/api/hecate.ts` (imported by `Dashboard.tsx`, route `/`, all roles). `HecateHandler.RegisterRoutes(read, admin *gin.RouterGroup)`: these 3 on `read`. | -| 14b | `hecate/*` — mutations: tunnels create/update/delete, `:uuid/start`, `:uuid/stop`, `:uuid/rotate-credentials`, `cloudflare/tunnels`, `:uuid/config/cloudflared`, `tailscale/devices`+`sync`, `zerotier/networks`(+members), `netbird/peers`+`sync` | `HecateHandler` | `management` | **ADMIN-ARG** (C1) — tunnel-provider credentials + network topology. All non-`read` `HecateHandler` routes go on the `admin` group. Frontend: no nav/route guard change — `/hecate/tunnels` etc. stay visible to `role=user` (list loads; create/edit controls 403), same as Access Lists. | -| 15a | `orthrus/agents` — reads: `GET /orthrus/agents`, `GET /orthrus/agents/:uuid` | `OrthrusHandler` | `management` | **READ (stays)** (C1) — `GET /orthrus/agents` is consumed by `frontend/src/components/hecate/ConnectionTypeSelector.tsx` (`useAgentList`), rendered inside the `role=user`-reachable proxy-host create/edit flow. `OrthrusHandler.RegisterRoutes(read, admin *gin.RouterGroup)`: these 2 on `read`. | -| 15b | `orthrus/agents` — mutations + detail: `POST /orthrus/agents`, `PATCH /:uuid`, `DELETE /:uuid`, `POST /:uuid/revoke`, `GET /:uuid/snippets`, `GET /:uuid/proxy-status` | `OrthrusHandler` | `management` | **ADMIN-ARG** (C1) — agent provisioning = trust-boundary expansion; install snippets embed a bootstrap token. All non-`read` `OrthrusHandler` routes on the `admin` group. Frontend: keep `RequireRole allowed={['admin']}` on `/hecate/agent` + its nav child (the agent-management page is admin-only; the read used by the proxy-host form is not gated). | -| 16 | `GET /dns-providers`, `/dns-providers/types`, `/dns-providers/:id`, `/dns-providers/detection-patterns` | `DNSProviderHandler`, `DNSDetectionHandler` | `management` (inside `if cfg.EncryptionKey != ""`) | **READ (stays)** — `DNSProviders` page is `role=user`-reachable and lists providers. | -| 17 | `GET /dns-providers/:id/audit-logs` (`auditLogHandler.ListByProvider`, `routes.go:521`) | `AuditLogHandler` | `management` | **MOVE → `managementAdmin`** (C5) — same actor-PII concern as row 28. Move this single `GET` to `managementAdmin`. (`DNSProviders` page does not surface per-provider audit logs to non-admins.) | -| 17b | `POST/PUT/DELETE /dns-providers*`, `POST /dns-providers/:id/test`, **`POST /dns-providers/test`** (id-less `TestCredentials`, `routes.go:519`), `POST /dns-providers/detect`, all `/:id/credentials*` (incl. `/:cred_id/test`), `POST /:id/enable-multi-credentials`, all `/dns-providers/:id/manual-challenge(s)*` | `DNSProviderHandler`, `CredentialHandler`, `ManualChallengeHandler` | `management` | **ADMIN-ARG** (C7) — DNS API credentials + ACME control. Inline registrations → per-route `RequireRole(admin)` arg; `ManualChallengeHandler.RegisterRoutes` → pass `managementAdmin` (all 6 routes are provider-mutation-adjacent; no `role=user` read need). Name both `POST /dns-providers/:id/test` **and** `POST /dns-providers/test` explicitly. | -| 18 | `GET /certificates`, `GET /certificates/:uuid` | `CertificateHandler` | `management` | **READ (stays)** — `Certificates` page is `role=user`-reachable. | -| 19 | `POST /certificates`, `POST /certificates/validate`, `PUT /certificates/:uuid`, `POST /certificates/:uuid/export`, `DELETE /certificates/:uuid` | `CertificateHandler` | `management` | **ADMIN-ARG** — `/export` returns private-key material. Per-route `RequireRole(admin)` args. | -| 20 | `GET /access-lists`, `/access-lists/:id`, `/access-lists/templates`, `POST /access-lists/:id/test` | `AccessListHandler` | `management` | **USER-OK** — `AccessLists` page is `role=user`-reachable; `/test` is a non-persisting dry-run IP check. Reads stay; add `POST /:id/test` to the enforcement-test allowlist. | -| 21 | `POST/PUT/DELETE /access-lists*` | `AccessListHandler` | `management` | **ADMIN-ARG** — ACLs are a security control. Per-route `RequireRole(admin)` args. | -| 22 | `GET /settings`, `GET /feature-flags`, `GET /themes` | `SettingsHandler`, `FeatureFlagsHandler`, `CustomThemeHandler` | `management` | **READ (stays)** — the SPA loads these for every role (`Layout.tsx` uses `getSettings`). | -| 23 | `POST/PATCH /settings`, `PATCH /config`, `POST/DELETE /settings/logo`, `/settings/banner`, `GET/POST /settings/smtp*`, `POST /settings/validate-url`, `/settings/test-url` | `SettingsHandler` | mixed (1 `RequireRole` arg, 8 in-handler refs) | **ADMIN-ARG** — normalize: per-route `RequireRole(admin)` arg on every settings mutation + `GET /settings/smtp` (keep its existing arg). Keep in-handler checks as belt-and-braces (do not remove — they predate this and some tests assert them). | -| 24 | `PUT /feature-flags` | `FeatureFlagsHandler` | `management` | **ADMIN-ARG** — per-route arg; `GET` stays. | -| 25 | `GET/POST/PUT/DELETE /themes` | `CustomThemeHandler` | `management` | **USER-OK** — code comment: "available to all management users (not admin-only)". No change; document. | -| 26 | `backups*`, `backups/remote-targets*` | `BackupHandler`, `BackupRemoteHandler` | `management` + in-handler `requireAdmin` on every mutation | **KEEP (in-handler)** — verify each mutation path has a `requireAdmin` test; no structural move required. | -| 27 | `users*` (`GET/POST/PUT/DELETE /users`, `/invite`, `/preview-invite-url`, `/permissions`, `/resend-invite`) | `UserHandler` | `management` + in-handler `requireAdmin` (except `UpdateUser` self-service branch) | **KEEP (in-handler)** — `UpdateUser` deliberately allows a non-admin to change their own name/password, so it cannot move wholesale. Verify tests cover the admin-only branches. | -| 28 | `GET /audit-logs`, `GET /audit-logs/:uuid` (`routes.go:428-429`) | `AuditLogHandler` | `management` | **MOVE → `managementAdmin`** (C4) — audit records expose other users' emails, source IPs, and security-event detail (info disclosure to a lower-privilege role). Both `GET`s → `managementAdmin`. Frontend: wrap the `/security/audit-logs` route element in `` (`App.tsx:104`). No dedicated nav entry exists for it (`Layout.tsx` `cerberus` children do not include audit-logs), so no nav filter needed; if the `Security` dashboard renders an in-page link to it, hide that link for non-admins (optional polish). | -| 29 | `GET /domains` | `DomainHandler` | `management` | **READ (stays)** — `Domains` page is `role=user`-reachable. | -| 30 | `POST /domains`, `DELETE /domains/:id` | `DomainHandler` | `management` | **ADMIN-ARG** — per-route `RequireRole(admin)` args. | -| 31 | `system/permissions*` (`GET`, `POST /repair`), `GET /system/updates`, `GET /system/my-ip`, `POST /system/uptime/check`, `POST /system/uptime/*` | `SystemPermissionsHandler`, `UpdateHandler`, `SystemHandler` | `management` (+ 3 in-handler refs in system-permissions) | **ADMIN-ARG** for `POST /system/permissions/repair` (arg) — keep `GET /system/permissions` as READ; `GET /system/updates`, `GET /system/my-ip` **USER-OK**; `POST /system/uptime/check` **USER-OK** (observability). | -| 32 | `uptime/monitors*`, `stats/*`, `cerberus/logs/ws`, `logs*`, `websocket/*` | various | `management` | **USER-OK** — observability / read. WS auth already via `AuthMiddleware`. No change; a few non-mutating `POST`s (`/uptime/sync`, `/uptime/monitors/:id/check`) — **allowlist**. | -| 33a | `notifications*` — `POST/PUT/DELETE /notifications/providers*`, `.../external-templates*` (Create/Update/Delete); `GET/PUT /notifications/settings/security` | `NotificationProviderHandler`, `NotificationTemplateHandler`, `SecurityNotificationHandler` | `management` + in-handler `requireAdmin` (verified: `notification_provider_handler.go` ×3, `notification_template_handler.go` ×3, `security_notifications.go` ×2) | **KEEP (in-handler)** — already guarded on Create/Update/Delete + settings. Verify tests. | -| 33b | `POST /notifications/providers/test` (`routes.go:658`), `POST /notifications/providers/preview` (`:659`), `POST /notifications/external-templates/preview` (`:668`) | `NotificationProviderHandler.Test`/`.Preview`, `NotificationTemplateHandler.Preview` | `management` | **ADMIN-ARG** (C3) — **verified NO in-handler `requireAdmin`** on `Test`/`Preview` (only Create/Update/Delete). These send test messages / render templates with provider config → admin-only. Add `middleware.RequireRole(models.RoleAdmin)` per-route arg. (Without this, the new enforcement test asserts 403 for `role=user` and fails with no guidance.) | -| 33c | `GET /notifications`, `POST /notifications/:id/read`, `POST /notifications/read-all` | `NotificationHandler` | `management` | **USER-OK** — per-user inbox. Allowlist the 2 read-state `POST`s. | -| 34 | `import` / NPM / JSON import (`RegisterImportHandler`) | `ImportHandler` etc. | `authenticatedAdmin` = `RequireRole(admin)` | **KEEP** — already correct. | - -**Companion frontend guards added by Part B** (mirroring the "Users" pattern — -`` on the route element + `user?.role === 'admin'` -spread on the nav entry). Required: - -- `/security/crowdsec` route + `navigation.crowdsec` nav child (Part A / row 1). -- `/security/audit-logs` route (C4 / row 28). No nav entry exists for it — - route guard only; optionally hide any in-page link from the `Security` - dashboard for non-admins. -- `/hecate/agent` route + its nav child (rows 15a/15b) — the Orthrus - *agent-management page* is admin-only; the `GET /orthrus/agents` read used by - the proxy-host form stays ungated so `ConnectionTypeSelector` still works for - `role=user`. -- `/security/encryption` route + `navigation.encryption` nav child — already - effectively admin via in-handler `isAdmin(c)`; add the guard for UX parity - (row 4). - -**NOT guarded** (pages stay visible to `role=user`; reads succeed, mutation -controls 403): Access Lists, Security Headers, DNS Providers, Certificates, -Domains, Remote Servers, and the Hecate *tunnels* page (`/hecate/tunnels`, -`/hecate/providers`). Optional follow-up ([§7](#7-remaining-open-questions)): -hide the disabled create/edit/delete controls on these pages for non-admins. -Do **not** guard `navigation.hecate` wholesale — its `remote-servers` and -`tunnels` children remain `role=user`-usable for reads. - -#### 3.2.3 Recommended structural fix (chosen) vs. alternative - -**Chosen:** one `managementAdmin := management.Group("/"); .Use(RequireRole(admin))` -subgroup (Part A) + the per-route/per-group moves in the table + a -deny-by-default enforcement test. Identical idiom to `securityAdmin` / -`authenticatedAdmin`. DRY. - -**Rejected as the sole mechanism:** a pure per-route `RequireRole` sweep with no -subgroup — that is exactly the opt-in model that produced this advisory -(`crowdsecHandler.RegisterRoutes` can't take per-route middleware without a -signature change and would still land ~45 routes on a bare group). We use the -per-route form only for the individually-registered mutations that sit next to -USER-OK reads (Q8). - -#### 3.2.4 New enforcement test — `routes_test.go` - -`TestManagementGroup_MutationsAreAdminGuarded`: - +| `POST` | `/notifications/providers/webpush/provision` | Generate VAPID keypair + create the singleton provider row | `RequireManagementAccess()`; provisioning is destructive-ish (any existing subscriptions become orphaned if re-run — see §7) so handler additionally checks `RequireRole(admin)`, mirroring `Test`/`Preview`'s existing admin-only pattern on this same route group | +| `GET` | `/notifications/providers/webpush/vapid-public-key` | Serve the current VAPID public key for `PushManager.subscribe` | `RequireManagementAccess()` (any authenticated user, not just admin — a non-admin user's browser can still subscribe to receive alerts, same as any authenticated user can view the Notifications page) | +| `POST` | `/notifications/providers/webpush/subscriptions` | Register (upsert) a browser's `PushSubscription` | `RequireManagementAccess()` | +| `GET` | `/notifications/providers/webpush/subscriptions` | List the **current user's own** subscriptions (for the "manage this device's subscription" UI state) | `RequireManagementAccess()` | +| `DELETE` | `/notifications/providers/webpush/subscriptions/:id` | Unsubscribe; 404 if the subscription isn't owned by the caller | `RequireManagementAccess()`; ownership checked in-handler (`subscription.UserID == c.GetString("userID")`), returning 404 (not 403) for a foreign ID to avoid confirming existence, consistent with this codebase's existing `respondSanitizedProviderError` pattern of not leaking cross-tenant existence | + +#### 3.4.0 Authorization model — resolved (§7 risk 5 closed by user decision) + +**Decision** (resolves the open question previously logged as §7 risk 5 — +this is now settled, not reopened): any authenticated user with management +access (`RequireManagementAccess()` — any role other than +`RolePassthrough`, so `RoleUser` included, not just `RoleAdmin`) may +self-service subscribe/list/unsubscribe their own Web Push destination, and +read the VAPID public key needed to do so. This is what the table above +already specifies for the four non-provision routes; provisioning itself +stays admin-only (`RequireRole(admin)`, row 1) since it creates the shared +singleton identity. + +**The security-event forwarding carve-out.** The four security-event +`NotifyXxx` toggles on a provider row (`NotifySecurityWAFBlocks`, +`NotifySecurityACLDenies`, `NotifySecurityRateLimitHits`, +`NotifySecurityCrowdSecDecisions`) are what actually gate whether security +telemetry gets forwarded to a given destination (`notification_service.go` +lines 243-249, `enhanced_security_notification_service.go` lines 110-119). +The exfiltration scenario Supervisor flagged: a low-privileged (`RoleUser`) +account self-service-subscribes a device pointed at an endpoint it +controls, then flips those four toggles on to have Charon forward WAF +blocks / ACL denies / rate-limit hits / CrowdSec decisions to it. + +**Finding**: this is already closed by existing code, with no new gate to +add. All four toggles are fields on `notificationProviderUpsertRequest` +(`backend/internal/api/handlers/notification_provider_handler.go:37-40`) +and are only ever set through the **generic** +`PUT /notifications/providers/:id` endpoint (`Update`, +`notification_provider_handler.go:216`) — there is no webpush-specific +"update my provider's toggles" route in this spec, and there never has +been one for any provider type. `Update` already calls `requireAdmin(c)` +unconditionally as its first line (`notification_provider_handler.go:217-219`, +same as `Create` at line 174), for **every** provider type, not just +webpush — this was confirmed by reading the handler, not assumed. So a +`RoleUser` caller can self-service-subscribe a device (via the five +webpush-specific routes above) but literally cannot reach a code path that +sets `NotifySecurityWAFBlocks` etc. on any provider, webpush included — +`Update` rejects them with `403` before the request body is even +inspected for which fields it's trying to change. + +**Consequence for implementation scope**: per the task's +root-cause-analysis instruction to prefer the minimum change that closes +the flagged gap, **no code change is needed here** — this is a +documentation/spec-confirmation finding, not a new webpush-specific +carve-out on `Update`, and not a tightening of the existing (already +admin-gated) behavior for other provider types either. A `RoleUser` +self-service subscriber receives whatever event categories (proxy hosts, +remote servers, domains, certs, uptime, and — only if an admin already +turned them on — the four security categories) are already enabled on the +webpush provider row at the time they subscribe; they cannot themselves +enable any category, security or otherwise, since all toggle changes go +through the same admin-gated `Update` endpoint. This matches the +requested shape exactly: self-service opt-in preserved, security-telemetry +forwarding to arbitrary endpoints impossible for a non-admin. +**Phase 2/commit 6 should add a regression test** asserting a `RoleUser` +token gets `403` from `PUT /notifications/providers/:id` when the target +row is `Type = "webpush"` with a body that sets any `NotifySecurityXxx` +field to `true` — this pins the *current* behavior so a future refactor of +`Update`'s auth check cannot silently reopen the gap Supervisor identified, +even though today's code already prevents it. + +#### 3.4.1 `POST /notifications/providers/webpush/provision` + +Request: +```json +{ "name": "Browser Push", "vapid_subject": "mailto:admin@example.com" } ``` -build router via Register(...); seed role=user and role=admin; mint JWTs. -for each route in router.Routes() where path starts /api/v1/ and method ∈ {POST,PUT,PATCH,DELETE}: - if route in PUBLIC_MUTATION_ALLOWLIST: continue // login, setup, invite/accept, security/events, emergency/security-reset (NOTE: auth/register REMOVED) - if route in USER_OK_MUTATION_ALLOWLIST: continue // proxy-hosts*, proxy-groups*, themes*, security-headers calculators (POST /score,/csp/validate,/csp/build), access-lists/:id/test, uptime sync + monitors/:id/check, notifications/:id/read + read-all, user self-service (PUT /users/:id), remote-servers reads are GET (not here) - send request with a valid role=user JWT → assert 403 // deny-by-default - send request with a valid role=admin JWT → assert != 403 // admin reaches handler +Response `201`: +```json +{ + "id": "uuid", + "name": "Browser Push", + "type": "webpush", + "enabled": true, + "service_config": "{\"vapid_public_key\":\"BN...\",\"vapid_subject\":\"mailto:admin@example.com\"}", + "notify_proxy_hosts": true, + "...": "...same NotificationProvider JSON shape as every other provider" +} ``` - -- Both allowlists are committed as explicit constants with a per-entry comment — - this list **is** the deny-by-default policy and is what the supervisor - reviews. -- Routes explicitly classified ADMIN-ARG in §3.2.2 that a reviewer might - otherwise expect on the allowlist (so they are **not** allowlisted and MUST - return 403 for `role=user`): `POST /notifications/providers/test`, - `POST /notifications/providers/preview`, - `POST /notifications/external-templates/preview` (C3); - `POST /dns-providers/test` + `POST /dns-providers/:id/test` (C7); - all Hecate mutation routes and all Orthrus mutation routes (C1). If any of - these lands on the bare `management` group at implementation time the test - fails — that is the intended tripwire. -- Also update `TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist`: - remove the `POST /api/v1/auth/register` entry from its `publicMutationAllowlist` - (route no longer exists — see Part C). - -### 3.3 Part C — Retire the public registration endpoint - -#### 3.3.1 Behavior change - -| Before | After | -|---|---| -| `POST /api/v1/auth/register {email,password,name}` → `201` (first caller `role=admin`, rest `role=user`) | Route does not exist → **`404`**. | -| First admin via `POST /api/v1/setup` | **Unchanged.** | -| Additional users: (undocumented) public register, or admin `POST /users` / `POST /users/invite` → `/accept-invite` | **Only** admin `POST /users` (direct create) or `POST /users/invite` → `GET /invite/validate` → `POST /invite/accept` (`/accept-invite` page). | - -No behavior change to `/setup`, `/users*`, `/invite/*`, `/auth/login`, -`/auth/logout`, `/auth/refresh`, `/auth/me`, `/auth/change-password`. - -#### 3.3.2 Backend deletions & edits (exact) - -**Delete:** - -- `backend/internal/api/routes/routes.go:295` — the line - `api.POST("/auth/register", authHandler.Register)`. -- `backend/internal/api/handlers/auth_handler.go` — `func (h *AuthHandler) Register` - (`:244-256`) and `type RegisterRequest struct` (`:238-242`). Remove any imports - that become unused as a result (compiler / `staticcheck` will flag). - -**Keep (do NOT delete — still referenced):** - -- `backend/internal/services/auth_service.go` — `func (s *AuthService) Register` - and its `count == 0 ⇒ RoleAdmin` logic. Referenced by ~28 test call sites - (`auth_service_test.go`, `middleware/auth_test.go`, - `handlers/user_integration_test.go`) as a user-creation helper. Add a doc - comment noting it is now an internal/test helper with no HTTP surface. - -**Edit (test references to the removed route):** - -- `backend/internal/api/routes/routes_test.go:162` — remove - `"/api/v1/auth/register"` from the `expectedRoutes` slice in - `TestRegister_RoutesRegistration`. -- `backend/internal/api/routes/routes_test.go:215` — remove the - `http.MethodPost + " /api/v1/auth/register": true` entry from - `publicMutationAllowlist`. -- `backend/internal/api/routes/routes_test.go:335` — change - `assert.Contains(t, routeMap, "/api/v1/auth/register")` to - `assert.NotContains(t, routeMap, "/api/v1/auth/register")` (or move the - assertion into the new Part C test, §3.3.4). -- `backend/internal/api/handlers/additional_coverage_test.go` — - `TestAuthHandler_Register_InvalidJSON` (`:717-732`, calls `h.Register(c)`): - delete this test (the handler it covers is gone). Adjust the file's imports if - needed. -- `backend/integration/crowdsec_lapi_integration_test.go:52-59` — the - `authenticate()` helper's "Register (may fail if user exists - that's OK)" - block: replace the `POST /api/v1/auth/register` call with - `POST /api/v1/setup` (same `{name,email,password}` shape; also tolerates a - "already completed" 403). Build-tagged integration test, not in default CI, - but must stay compilable/correct. - -#### 3.3.3 `util.GenerateSecureToken` promotion — **DROPPED** - -The earlier draft promoted `user_handler.go`'s `generateSecureToken()` to -`backend/internal/util` for the now-cancelled invite pool. Nothing else needs -it. **No refactor** — `generateSecureToken()` stays unexported in -`user_handler.go` exactly as-is. - -#### 3.3.4 Tests — new `backend/internal/api/routes/routes_test.go` - -`TestRegister_PublicRegistrationEndpointRemoved`: - -| Case | Request | Expected | -|---|---|---| -| Route gone | `POST /api/v1/auth/register {…}` (no auth) | `404` | -| Route gone (any method) | `GET /api/v1/auth/register` | `404` | -| Bootstrap intact | `GET /api/v1/setup` on empty DB | `200 {"setupRequired":true}` | -| Bootstrap intact | `POST /api/v1/setup {name,email,password}` on empty DB | `201`; a `role=admin` user exists; `caddy.acme_email` setting written | -| Bootstrap closed after first | `POST /api/v1/setup` again | `403 {"error":"Setup already completed"}` | -| Email-invite intact | admin `POST /api/v1/users/invite {email}` → `GET /api/v1/invite/validate?token=…` → `POST /api/v1/invite/accept {token,name,password}` | invite validates; acceptance `200`; the invited user is `enabled` and can `POST /api/v1/auth/login` | - -`AuthService.Register` unit tests in `auth_service_test.go` are unchanged -(the method is unchanged). - -#### 3.3.5 Frontend - -- **No new pages, routes, api modules, or hooks.** -- `frontend/src/api/*` — confirm no `auth/register` caller exists (grep already - shows none). No edit. -- `frontend/src/pages/AcceptInvite.tsx`, `frontend/src/api/users.ts`, - `frontend/src/pages/UsersPage.tsx` — unchanged by Part C. `UsersPage` remains - the admin surface for creating/inviting users. -- Optional 1-line doc/help-text touch if any onboarding copy mentions - self-signup (grep `i18n` for "register" / "sign up" in - `frontend/src/locales` — likely none; skip if absent). - -### 3.4 Data flow (after this feature) - +Errors: `400` invalid/missing `vapid_subject` scheme; `409` a `webpush` +provider row already exists (`{"error": "a Web Push provider is already configured"}`) +— returned both for the common case (service-layer `COUNT` fast-path finds +an existing row) and the race case (the `INSERT` loses to a concurrent +request at the `idx_webpush_singleton` partial unique index; see §3.1 +"Enforcement" and §3.3.4). Both paths produce the identical response body +— the client has no way to tell them apart and does not need to. + +#### 3.4.2 `GET /notifications/providers/webpush/vapid-public-key` + +Response `200`: `{"vapid_public_key": "BN..."}`. `404` if no `webpush` +provider row exists yet (frontend shows a "not yet enabled" state, prompting +provisioning by an admin) or if `Enabled = false` (subscribing while +disabled would create dead-on-arrival subscriptions). + +#### 3.4.3 `POST /notifications/providers/webpush/subscriptions` + +Request (body is the browser's `PushSubscription.toJSON()` shape, +matching the W3C spec verbatim so the frontend can forward it with no +reshaping): +```json +{ + "endpoint": "https://fcm.googleapis.com/fcm/send/...", + "keys": { "p256dh": "BN...", "auth": "xy..." }, + "user_agent": "Mozilla/5.0 ..." +} ``` -First run (no users) - │ POST /api/v1/setup {name,email,password} - ▼ -api (public) → UserHandler.Setup → tx{ INSERT users(role=admin, enabled=true) ; upsert Setting caddy.acme_email } - ▼ 201 - -Add a user (admin only) - │ admin → POST /api/v1/users {email,name,password,role?} (direct) - │ or → POST /api/v1/users/invite {email,role?} → email/link → /accept-invite?token=… → POST /api/v1/invite/accept - ▼ UserHandler.CreateUser / InviteUser / AcceptInvite (all existing, unchanged) - -Removed - │ POST /api/v1/auth/register … - ▼ 404 (route deleted) - -Attacker with a role=user token (however obtained) - │ POST /api/v1/admin/crowdsec/stop → management → managementAdmin → RequireRole(admin) → 403 (Part A) - │ POST /api/v1/admin/plugins/x/enable → RequireRole(admin) arg → 403 (Part B #3) - │ POST /api/v1/certificates/x/export → RequireRole(admin) arg → 403 (Part B #19) - │ GET /api/v1/certificates → management → 200 (READ stays — non-admin page needs it) (Part B #18) +Response `201` (new) or `200` (idempotent re-registration of an existing +`endpoint`, refreshing `LastSeenAt`/`UserAgent`/resetting `FailureCount`): +```json +{ "id": "uuid", "endpoint": "https://fcm.googleapis.com/fcm/send/..." } ``` +Validation: `endpoint` required, must parse as an `https://` URL (reject +`http://` outright — Web Push endpoints are always HTTPS in production; +this is a basic format check, not a re-run of `security.ValidateExternalURL`, +since the actual SSRF-safe validation happens at send time via the shared +`transport.Wrapper`, §2.2); `keys.p256dh`/`keys.auth` required +non-empty strings. `404` if no `webpush` provider is provisioned. +`503` (`{"error": "Web Push provider is disabled"}`) if the singleton row's +`Enabled = false`. -### 3.5 Error handling & edge cases +#### 3.4.4 `GET /notifications/providers/webpush/subscriptions` -| Case | Handling | -|---|---| -| `POST /auth/register` after deploy | `404` (Gin default no-route). Covered by test. | -| Client / script still POSTing `/auth/register` | Gets `404`; must switch to `/setup` (bootstrap) or admin invite. Called out in `ARCHITECTURE.md` + release notes. | -| `/setup` on an already-bootstrapped instance | `403 {"error":"Setup already completed"}` (existing logic, unchanged). | -| Concurrent `/setup` calls on empty DB | Existing `isSetupConflictError` / post-tx count re-check handles it (unchanged). | -| Removing `RegisterRequest` leaves an unused import in `auth_handler.go` | `goimports` / `staticcheck` catches; remove in the same commit. | -| `additional_coverage_test.go` import set after deleting the test | Adjust; `go build ./...` + `go vet` verify. | -| A moved route (Part B) that a `role=user` UI screen actually needs | Prevented by the mutation-vs-read classification (Q7) + frontend E2E asserting `role=user` still `200`s on the READ endpoints + still loads the non-gated pages. | -| `role=user` opens a page whose *mutations* now 403 (Access Lists, Security Headers, DNS Providers, Certificates, Domains, Remote Servers, Hecate tunnels) | Page loads (reads succeed — verified consumed by `role=user` screens); create/edit/delete return 403 `{"error":"Forbidden"}`. Acceptable; optional follow-up to hide the buttons ([§7](#7-remaining-open-questions)). | -| `role=user` opens an admin-only page (CrowdSec, Audit Logs, Orthrus agent-management, Encryption) | Companion `RequireRole` guard redirects them away; nav entry hidden where one exists — same UX as "Users" today. No dead page. | -| `ConnectionTypeSelector` / Dashboard hecate widget for `role=user` after Part B | Their reads (`GET /orthrus/agents`, `GET /hecate/status`, `GET /hecate/tunnels`) stay on `management` — verified they still return `200`. E2E asserts this. | -| GORM security scan | No model / query changes in this feature → `scripts/scan-gorm-security.sh` is N/A, but run it anyway if any handler file under `backend/internal/models/**` is touched (none expected). | -| Migration impact | None — no schema change. | - ---- +Response `200`: array of `{id, endpoint, user_agent, created_at, last_seen_at}` +for the caller's own `UserID` only (never another user's rows — reinforces +per-user device management without a cross-user admin view in this PR; +an admin wanting to see *all* subscribers' device counts is a documented +non-goal/follow-up, §7). -## 4. Implementation Plan - -### Phase 1 — E2E specs (behavior, as `test.fixme`) - -- `tests/security-enforcement/crowdsec-admin-authz.spec.ts` (new) — `role=user` - → `403` on `/admin/crowdsec/stop`, `/bouncer/key`, `/ban`, `/file`; - unauthenticated → `401`; `role=admin` → not `403`. -- Extend `tests/security-enforcement/authorization-rbac.spec.ts` — - `role=user` → `403` on: `POST /admin/plugins/:id/enable`, - `POST/DELETE /remote-servers*` (+ `POST /remote-servers/test`), - Hecate mutations (`POST /hecate/tunnels`, `POST /hecate/tunnels/:uuid/start`, - `POST /hecate/tailscale/sync`), Orthrus mutations (`POST /orthrus/agents`, - `DELETE /orthrus/agents/:uuid`, `GET /orthrus/agents/:uuid/snippets`), - `POST/PUT/DELETE /dns-providers*`, `POST /dns-providers/test`, - `POST /notifications/providers/test`, `POST /notifications/providers/preview`, - `POST /certificates/:uuid/export`, `POST/PUT/DELETE /access-lists*`, - `POST/DELETE /domains*`, `POST/PATCH /settings`, `GET /audit-logs`, - `GET /dns-providers/:id/audit-logs`; - `role=user` still `200` on `GET /proxy-hosts`, `GET /settings`, - `GET /themes`, `GET /certificates`, `GET /access-lists`, `GET /dns-providers`, - `GET /hecate/status`, `GET /hecate/tunnels`, `GET /orthrus/agents`, - `GET /remote-servers`. -- `role=user` navigating directly to `/security/crowdsec`, - `/security/audit-logs`, `/hecate/agent`, `/security/encryption` is redirected - (companion `RequireRole` guards); those nav entries are absent for `role=user` - where a nav entry exists. -- `tests/security-enforcement/public-registration-removed.spec.ts` (new) — - `POST /api/v1/auth/register` → `404`; `/setup` bootstrap still works on a - fresh instance; existing email-invite acceptance flow - (`/users/invite` → `/invite/validate` → `/invite/accept` → login) still works. -- All `test.fixme` until Phase 2/3 land; un-fixme in Phase 4. -- **Dropped from the earlier plan:** `invite-registration.spec.ts`. - -### Phase 2 — Backend - -- **Commit 2 (Part A):** `managementAdmin` subgroup decl; - `crowdsecHandler.RegisterRoutes(managementAdmin)`; companion frontend guard - (`/security/crowdsec` route + nav); `routes_test.go` regression (§3.1.4). - `fix(security):`. -- **Commit 3 (Part B):** re-run audit; apply the §3.2.2 table: - - Wholesale: CrowdSec (done in Commit 2). - - `RegisterRoutes(read, admin)` split: `HecateHandler`, `OrthrusHandler`, - `RemoteServerHandler` (reads listed in rows 11/14a/15a stay on `management`; - all other routes → `managementAdmin`). - - `SecurityHeadersHandler`: **delete** its `RegisterRoutes` method; register - its ~11 routes inline in `routes.go` (reads + 3 calculators on `management`, - profile mutations + `presets/apply` on `managementAdmin`). - - Per-route `RequireRole(admin)` args: plugin enable/disable/reload; - dns-provider mutations + `POST /dns-providers/test` + `POST /dns-providers/:id/test` - + credentials + `POST /dns-providers/detect`; `ManualChallengeHandler.RegisterRoutes(managementAdmin)`; - certificate mutations incl. `/export`; access-list mutations; domain - mutations; settings mutations (+ keep existing `GET /settings/smtp` arg); - `PUT /feature-flags`; `POST /system/permissions/repair`; - `POST /notifications/providers/test` + `/preview` + `/external-templates/preview`. - - `MOVE → managementAdmin`: `GET /audit-logs`, `GET /audit-logs/:uuid`, - `GET /dns-providers/:id/audit-logs`; `adminEncryption` group decl → - `managementAdmin.Group("/admin/encryption")` (defense-in-depth). - - Companion frontend guards: `` on - `/security/audit-logs`, `/hecate/agent` (+ nav child), - `/security/encryption` (+ nav child); `/security/crowdsec` already done. - - `TestManagementGroup_MutationsAreAdminGuarded` + `USER_OK_MUTATION_ALLOWLIST` - + `PUBLIC_MUTATION_ALLOWLIST` (reviewed constants); update - `TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist`. - `fix(security):`. -- **Commit 4 (Part C):** delete `/auth/register` route + `AuthHandler.Register` - + `RegisterRequest`; keep `AuthService.Register`; update the 4 backend test - references + the integration-test helper; new - `TestRegister_PublicRegistrationEndpointRemoved`. `fix(security):`. - -### Phase 3 — Frontend - -Rolled into Commits 2 & 3 (the companion `RequireRole` guards + nav filters are -small and belong with the backend change that necessitates them). No standalone -frontend commit — there is no new UI in this feature. - -### Phase 4 — Integration, hardening, docs - -- **Commit 5:** un-`fixme` the Phase 1 specs; run targeted specs (firefox). - File a follow-up issue for a general per-IP auth throttle middleware (out of - scope — noted, not built). Update `ARCHITECTURE.md`, `SECURITY.md`, - `docs/security.md`, `docs/features/access-control.md`, `docs/features.md`, - `docs/features/crowdsec.md`, `docs/features/custom-plugins.md` / - `plugin-security.md`. `docs:`. - ---- +#### 3.4.5 `DELETE /notifications/providers/webpush/subscriptions/:id` -## 5. Acceptance Criteria (Definition of Done) - -1. **Advisory closed:** unauthenticated → `401`, `role=user` → `403`, - `role=admin` → handler executes, on `/admin/crowdsec/stop`, - `/admin/crowdsec/bouncer/key`, `/admin/crowdsec/ban`, - `/admin/crowdsec/file`. Proven by `routes_test.go` + E2E. -2. **Plugins mutations closed:** `role=user` → `403` on - `POST /admin/plugins/:id/enable|disable`, `POST /admin/plugins/reload`; - `GET /admin/plugins*` still `200` for `role=user`. -3. **Deny-by-default:** `TestManagementGroup_MutationsAreAdminGuarded` passes; - every mutating `/api/v1/*` route is admin-guarded or on a reviewed allowlist - with a per-entry comment. -4. **Public registration gone:** `POST /api/v1/auth/register` → `404` (route - absent from `router.Routes()`). -5. **Bootstrap + invites intact:** `/setup` first-admin flow succeeds on a - fresh instance and 403s afterward; email-invite - (`/users/invite` → `/invite/validate` → `/invite/accept` → login) succeeds. - Regression tests prove both. -6. **`AuthService.Register` retained** and all its existing unit tests pass - unchanged; `AuthHandler.Register` / `RegisterRequest` / the register route - are removed with no dangling references (`go build ./...`, `staticcheck`, - `go vet` clean). -7. **No `role=user` dead pages:** admin-only pages (CrowdSec, **Audit Logs**, - the Orthrus agent-management page `/hecate/agent`, Encryption) are hidden - from `role=user` in nav and redirect on direct navigation. READ-classified - pages (Access Lists, Certificates, DNS Providers, Security Headers, Domains, - Remote Servers, Hecate tunnels) still load for `role=user`, and their reads - (`GET /orthrus/agents`, `GET /hecate/status`, `GET /hecate/tunnels`, - `GET /remote-servers`, …) still return `200`. Frontend E2E covers both. -8. **Coverage:** backend ≥ 85 % (`scripts/go-test-coverage.sh`), frontend - ≥ 85 % (`scripts/frontend-test-coverage.sh`); patch coverage green - (`bash scripts/local-patch-report.sh` → `test-results/local-patch-report.{md,json}`). -9. **Security gates:** `lefthook run pre-commit` (CodeQL Go + JS) 0 - high/critical; `make trivy` clean; `make lint-fast` / staticcheck clean. - (`scripts/scan-gorm-security.sh --check` if any `models/**` file is touched — - none expected.) -10. **Targeted E2E green (firefox only):** `crowdsec-admin-authz.spec.ts`, - `authorization-rbac.spec.ts`, `public-registration-removed.spec.ts`, - `auth-api-enforcement.spec.ts`. Full-suite / cross-browser deferred to CI. -11. **Type safety / build:** `cd frontend && npm run type-check` clean; - `cd backend && go build ./...`; `cd frontend && npm run build`. -12. **Docs:** `ARCHITECTURE.md` + `SECURITY.md` reflect the new authorization - boundary and the removal of public self-registration. +`204` on success. `404` if not found or not owned by the caller. ---- - -## 6. Complexity Estimates - -| Component | Complexity | Notes | -|---|---|---| -| Part A route move + frontend guard + tests | **Low** | 2-line routing change, 1 route wrap + 1 nav filter, 1 test file. | -| Part B audit + moves + splits + enforcement test | **Medium-High** | ~35 registration sites reviewed; ~16 per-route `RequireRole` args; 3 handlers gain a `RegisterRoutes(read, admin)` split (`Hecate`, `Orthrus`, `RemoteServer`); `SecurityHeadersHandler.RegisterRoutes` deleted + inlined; `GET /audit-logs*` + 1 per-provider audit read moved; 4 companion frontend `RequireRole` guards; new enforcement test + 2 reviewed allowlists; risk of a mis-classified `role=user` read (mitigated by `frontend/src` verification + E2E). | -| Part C deletions | **Low** | Delete 1 route + 1 handler + 1 struct; keep the service; fix 4 test refs + 1 integration helper; 1 new test. | -| Docs | **Low** | | +### 3.5 Dispatch fan-out design ---- +New function in `notification_service.go`, invoked from `SendExternal`'s +existing per-provider dispatch loop as a new branch parallel to the +existing `email` special case: -## 7. Remaining open questions - -All earlier open questions and all supervisor blocking/should-fix items are -resolved and baked into the spec: - -- Invite pool dropped → Q1/Q2/Q5 moot. -- Q6 — subgroup-only, no belt-and-braces in-handler `requireAdmin`. -- Q7 / C1 / C2 — mutation-vs-read classification; Hecate / Orthrus / - RemoteServer use a `RegisterRoutes(read, admin)` split (NOT wholesale move), - reads verified against `frontend/src`. -- C3 — `POST /notifications/{providers/test,providers/preview,external-templates/preview}` - added to the table as ADMIN-ARG; §2.1 in-handler audit row corrected. -- C4 / §7.1 — **resolved in this PR**: `GET /audit-logs*` → `managementAdmin` + - `` on `/security/audit-logs`. -- C5 — `GET /dns-providers/:id/audit-logs` → `managementAdmin`. -- C6 / §7.3 — **resolved**: `SecurityHeadersHandler.RegisterRoutes` deleted, its - routes inlined in `routes.go` with per-route args (matches its siblings). -- C7 — `POST /dns-providers/test` (id-less `TestCredentials`) named explicitly, - separate from `POST /dns-providers/:id/test`. -- Q4 — per-IP auth throttle: deferred, tracking issue filed in Commit 5. - -**Only remaining item — deferred UX polish (not a blocker, tracked in Commit 5):** - -1. Hide the disabled create/edit/delete controls for `role=user` on the - READ-classified pages (Access Lists, Certificates, DNS Providers, Security - Headers, Domains, Remote Servers, Hecate tunnels). The API already enforces - `403`; this is cosmetic. Out of scope for this PR; tracking issue filed - alongside the auth-throttle issue in Commit 5. +```go +if strings.ToLower(strings.TrimSpace(provider.Type)) == "email" { + go s.dispatchEmailViaNotify(ctx, provider, eventType, title, message) + continue +} +if strings.ToLower(strings.TrimSpace(provider.Type)) == "webpush" { + go s.dispatchWebPushViaNotify(ctx, provider, eventType, title, message, data) + continue +} +``` ---- +`dispatchWebPushViaNotify` (new, `notify_webpush_adapter.go` — new file, +mirroring the existing per-concern-file split of +`notify_provider_adapter.go`/`notify_email_adapter.go`/`notify_client_adapter.go`): + +1. Parse `provider.ServiceConfig` → `vapid_public_key`, `vapid_subject`; + `provider.Token` → `vapid_private_key`. Malformed/missing → log and + return (matches `dispatchViaNotify`'s existing "log and return" error + style, no panics). +2. `s.DB.Where("provider_id = ?", provider.ID).Find(&subscriptions)`. +3. For each subscription, construct one `webpush.Client` via + `webpush.New(webpush.Config{VAPIDPublicKey: ..., VAPIDPrivateKey: ..., + VAPIDSubject: ..., Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: + sub.Auth, Template: tmpl, CustomTemplate: customTemplate}, s.notifyWrapper)` + (reusing `resolveTemplateFields`, unchanged, from + `notify_provider_adapter.go`) and call `Send` — **sequentially within + the already-async outer goroutine, not one goroutine per subscription**. + Rationale: `SendExternal` already backgrounds each *provider* dispatch in + its own goroutine (`go s.dispatchWebPushViaNotify(...)`); nesting a + second layer of per-subscription goroutines is unbounded fan-out with no + cap (a provider with hundreds of stale subscriptions would spawn + hundreds of concurrent outbound HTTP requests) — sequential-within-one- + goroutine bounds concurrency to 1 outbound push service call at a time + per dispatch event, trading a little latency (bounded further by + `transport.Wrapper`'s existing 3-attempt/200ms-2s retry policy applying + per subscription) for predictable resource usage. This is flagged as a + documented, deliberate trade-off in §7, not an oversight — a future + worker-pool-bounded-concurrency improvement is a legitimate follow-up if + subscription counts grow large in practice, but is out of scope here + (no existing precedent in this codebase for bounded worker pools in the + notification path to follow). +4. **Partial-failure handling** (per-subscription, independent — matching + the module's own `docs/INTEGRATION.md` example of logging each error + independently rather than aborting): + - `Send` returns `nil`: update `LastSeenAt = now()`, reset + `FailureCount = 0`. + - `Send` returns an error: parse the numeric HTTP status out of the + error string via a small helper, + `extractHTTPStatusFromNotifyError(err error) (status int, ok bool)`, + using a regex (`provider returned status (\d+)`) matched against + `err.Error()` — **necessary because, per §2.1, `transport.Wrapper` + exposes no typed status error.** This is fragile-by-construction + (an upstream wording change silently breaks detection) so: + - If `ok && (status == 404 || status == 410)`: delete the subscription + row immediately (standard Web Push "subscription is gone" signal — + RFC 8030 doesn't mandate this status/action pairing itself, but it + is the universal convention every push service and every Web Push + client library follows). + - Otherwise (`!ok`, or any other status, or a non-HTTP transport + error): increment `FailureCount`, set `LastFailureAt = now()`; if + `FailureCount >= 10` (new const `webpushMaxConsecutiveFailures`), + delete the row as presumed-dead (a bound on rows accumulating + forever from a subscription failing for non-404/410 reasons, e.g. a + push service outage that never resolves before it starts returning + 410) — chosen instead of never deleting on ambiguous failures, since + an unbounded table of permanently-failing rows is its own + maintenance problem, and instead of deleting on the very first + ambiguous failure, since a single transient failure (network blip, + push service 503) must not nuke a live subscription. + - Log every failure via the existing `logger.Log().WithError(err)...` + convention (`dispatchViaNotify`'s existing style) — never silent. +5. This function does not return an error to `SendExternal` (matching the + fire-and-forget style of every existing `dispatchXxxViaNotify`); it logs + per-subscription outcomes only. + +### 3.6 Frontend + +#### 3.6.1 Service worker + +New file `frontend/public/sw.js` (static asset, served at `/sw.js` — root +scope required so `PushManager.subscribe` can receive pushes for the whole +origin, per the W3C Push API's same-scope requirement). Minimal handler: + +```js +self.addEventListener('push', (event) => { + const data = event.data ? event.data.json() : {}; + const title = data.title || 'Charon'; + event.waitUntil( + self.registration.showNotification(title, { + body: data.message || data.body || '', + icon: '/favicon.png', + data, + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil(clients.openWindow('/')); +}); +``` -## 8. Risks & Mitigations +The JSON shape (`title`/`message`) matches the `minimal`/`detailed` +template's existing field names (`render.MinimalTemplate`), so no new +payload contract is invented — the service worker just needs to +`JSON.parse` what `dispatchWebPushViaNotify` already renders via the shared +template engine. + +#### 3.6.2 API client — `frontend/src/api/notifications.ts` + +- `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` gains `'webpush'`. +- New typed functions: + - `provisionWebPush(data: {name: string; vapid_subject: string}): Promise` + - `getWebPushVapidPublicKey(): Promise<{vapid_public_key: string}>` + - `subscribeWebPush(subscription: PushSubscriptionJSON & {user_agent?: string}): Promise<{id: string; endpoint: string}>` + - `listWebPushSubscriptions(): Promise` + - `unsubscribeWebPush(id: string): Promise` +- New exported type `WebPushSubscription` (id, endpoint, user_agent, + created_at, last_seen_at) matching §3.4.4's response shape. + +#### 3.6.3 UI — `frontend/src/pages/Notifications.tsx` + +Following the existing per-type conditional-field pattern +(`isGotify`/`isTelegram`/.../`isNtfy` constants, lines 147-152): add +`isWebPush = type === 'webpush'`. When `isWebPush`: +- If no `webpush` provider row exists yet: render a "Provision Web Push" + button (calls `provisionWebPush`) instead of the generic URL/Token + fields, consistent with §3.2's dedicated-provisioning-flow decision. +- If provisioned: render a browser-side "Enable push notifications on this + device" toggle that, on enable, does: + 1. `Notification.requestPermission()` (browser permission prompt). + 2. `navigator.serviceWorker.register('/sw.js')`. + 3. `registration.pushManager.subscribe({userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)})` (a + small base64url→`Uint8Array` helper is required — the browser + `PushManager` API needs the raw bytes, not the string Charon stores; + this is standard boilerplate for every Web Push frontend integration, + not Charon-specific). + 4. `subscribeWebPush(subscription.toJSON())`. + - On disable: `subscription.unsubscribe()` (browser-side) then + `unsubscribeWebPush(id)` (backend-side) — both directions, so a + revoked browser permission and a deleted backend row stay consistent. +- The per-event-type `NotifyXxx` checkboxes already rendered generically + for every provider type require no changes — they apply to the + `NotificationProvider` row exactly as they do for every other type. + +### 3.7 `go.mod` bump -| Risk | Impact | Mitigation | -|---|---|---| -| A READ endpoint mis-classified as ADMIN regresses a `role=user` page | `role=user` UI breaks | Q7 mutation-vs-read rule; frontend E2E asserts `role=user` keeps `GET` access + page loads for every READ-classified area; classification table in PR description; each commit individually revertable. | -| An admin-gated capability was actually needed by `role=user` | Lost functionality for `role=user` | Only CrowdSec moves wholesale (no `role=user` read). Hecate / Orthrus / RemoteServer keep their `role=user`-consumed `GET` reads on `management` (verified: `ConnectionTypeSelector` → `GET /orthrus/agents`, `Dashboard` → `GET /hecate/status`); only mutations move. Audit Logs / Orthrus agent page / Encryption become admin-only with a companion `RequireRole` guard (explicit redirect, not a silent 403). If a real `role=user` need surfaces, revert Commit 3 alone — Commit 2 (advisory fix) still stands. | -| Removing `RegisterRequest`/`Register` leaves dangling refs | Build break | grep evidence in §2.1 enumerates every reference; `go build ./...` + `staticcheck` + `go vet` in the commit gate; integration-test helper explicitly updated. | -| `AuthService.Register` mistakenly deleted | ~28 test call sites fail to compile | Spec is explicit: **keep** it; it is not dead. | -| Advisory still private / embargoed | Disclosure via commit message / changelog | `fix(security):` subjects deliberately vague — category + mitigation only, never "CrowdSec", "authorization bypass", "public registration", or route paths (§10). No GHSA id in subjects or changelog-visible lines. | -| `publicMutationAllowlist` still lists `auth/register` after route removal | Enforcement test references a non-existent route | Commit 4 removes that entry (§3.3.2). | -| Coverage dip from the large Part B routing diff | PR fails 85 % gate | New tests target new/moved code paths; `local-patch-report.sh` preflight before pushing. | -| Companion frontend guards missed for an admin-only page | `role=user` hits a 403-ing page | E2E: for `/security/crowdsec`, `/security/audit-logs`, `/hecate/agent`, `/security/encryption`, assert a `role=user` session is redirected and (where a nav entry exists) it is absent. | -| A non-mutating `POST` (`/access-lists/:id/test`, `/security/headers/score` etc.) breaks for `role=user` because it's a POST | `role=user` diagnostic feature 403s | These are explicitly in `USER_OK_MUTATION_ALLOWLIST` (§3.2.4) and stay on `management`; the enforcement test asserts `role=user` is NOT 403 for them. | +``` +github.com/Wikid82/go_notify_yourself v0.2.2 → v0.3.0 +``` +`go get github.com/Wikid82/go_notify_yourself@v0.3.0 && go mod tidy` in +`backend/`. No other dependency in the module's own `go.mod` changed +between v0.2.2 and v0.3.0 in a way that adds a new transitive dependency +Charon doesn't already have (the module's diff between these tags is the +webpush package plus its supporting stdlib-only `crypto/ecdsa`, +`crypto/elliptic` usage — no new third-party import) — **verify this +holds at implementation time** via `go mod why` / diffing `go.sum` before +and after the bump, since this spec's research window only inspected the +webpush package's own imports, not a full `go.sum` diff. Flag any +unexpected new transitive dependency to the `qa-security` agent's +Trivy/CodeQL pass rather than assuming it's clean. + +### 3.8 Error handling summary + +| Failure | Handling | +|---|---| +| `webpush.GenerateVAPIDKeyPair()` fails (crypto/rand exhaustion — effectively never) | `500`, logged, provision endpoint returns error, no row created | +| Second provision attempt while a `webpush` row exists | `409`, no mutation | +| Two provision requests race concurrently (both pass the `COUNT` fast-path before either's `INSERT` commits) | The `idx_webpush_singleton` partial unique index (§3.3.4) fails the losing `INSERT`; `CreateProvider` catches the constraint-violation error and returns the same `409` as the non-race case (§3.4.1) — never a raw `500` | +| `PushManager.subscribe` rejected by browser (permission denied) | Frontend-only; no backend call made; UI shows a non-blocking inline message, no `NotificationXxx` internal-notification row created (matches: permission denial isn't a Charon-side error) | +| `POST .../subscriptions` with malformed `PushSubscription` shape | `400`, validation message, no row created | +| Send to a subscription returns 404/410 | Row deleted, no admin-facing internal notification generated (silent, expected steady-state cleanup — consistent with no other provider type raising an internal notification on send failure either) | +| Send to a subscription fails for another reason, `FailureCount < 10` | Logged only, row retained, `FailureCount` incremented | +| Send to a subscription fails, `FailureCount` reaches 10 | Row deleted, logged at `Warn` (elevated from the default failure `Error` log, to make bulk pruning visible in logs without a dedicated internal-notification row) | +| VAPID keypair provisioned, but zero subscriptions exist yet | `dispatchWebPushViaNotify` finds zero rows, no-op, no error | +| `provider.Enabled = false` | Same as every other type: `SendExternal`'s outer `Where("enabled = ?", true)` query already excludes it before `dispatchWebPushViaNotify` is ever called — no separate check needed inside the new function | --- -## 9. Commit Slicing Strategy - -**Decision:** ONE PR, merged only when the whole feature is complete and the -full Definition of Done passes. Reviewability comes from the ordered commit -sequence below — **not** from splitting into backend/frontend/security PRs. -Each commit builds and passes its own validation gate. Order follows -`CLAUDE.md` "Suggested Commit Sequence" (E2E fixme → backend → frontend → -hardening+docs); the advisory fix (Part A) is placed first after the specs so it -is independently revertable. Part C collapsed to a single deletion commit — the -5-commit plan replaces the earlier 7. - -Base branch: `development`. +## 4. Component Design / Data Flow + +```mermaid +sequenceDiagram + participant Browser + participant SW as Service Worker + participant API as Charon Backend + participant DB as SQLite + participant Push as Push Service (FCM/Mozilla/etc.) + + Note over Browser,API: Provisioning (once, by an admin) + Browser->>API: POST /notifications/providers/webpush/provision + API->>API: webpush.GenerateVAPIDKeyPair() + API->>DB: INSERT NotificationProvider(type=webpush) + API-->>Browser: 201 provider row + + Note over Browser,API: Subscribing (per device) + Browser->>API: GET .../vapid-public-key + API-->>Browser: {vapid_public_key} + Browser->>SW: navigator.serviceWorker.register('/sw.js') + Browser->>Browser: pushManager.subscribe({applicationServerKey}) + Browser->>API: POST .../subscriptions {endpoint, keys} + API->>DB: INSERT WebPushSubscription + + Note over API,Push: Dispatch (on any notifiable event) + API->>DB: SendExternal loads enabled providers + API->>DB: dispatchWebPushViaNotify loads subscriptions for provider + loop each subscription + API->>Push: webpush.Client.Send (VAPID JWT + RFC8291 ciphertext) + alt 404/410 + API->>DB: DELETE subscription + else success + API->>DB: UPDATE last_seen_at + else other failure + API->>DB: UPDATE failure_count + end + end + Push->>SW: push event + SW->>Browser: showNotification() +``` --- -### Commit 1 — E2E specs for new behavior (`test.fixme`) - -- **Type:** `test: add fixme e2e specs for privileged-route authz and removal of public registration` -- **Scope:** Author (as `test.fixme`) the Playwright specs for Parts A/B/C. No - product code. -- **Files:** - - `tests/security-enforcement/crowdsec-admin-authz.spec.ts` (new) - - `tests/security-enforcement/public-registration-removed.spec.ts` (new) - - `tests/security-enforcement/authorization-rbac.spec.ts` (extend: plugin - mutations, remote-server mutations, Hecate mutations, Orthrus mutations - (incl. `/snippets`), dns-provider mutations + `POST /dns-providers/test`, - notification `test`/`preview`, cert `/export`, access-list/domain/settings - mutations, `GET /audit-logs*`; + `role=user` positive READ cases incl. - `GET /orthrus/agents`, `GET /hecate/status`, `GET /hecate/tunnels`, - `GET /remote-servers`; + admin-only nav/redirect checks for - `/security/crowdsec`, `/security/audit-logs`, `/hecate/agent`, - `/security/encryption`) -- **Depends on:** nothing. -- **Validation gate:** - `npx playwright test crowdsec-admin-authz public-registration-removed authorization-rbac --project=firefox` - collects specs, all `fixme`/skipped, 0 failures; `eslint` clean on the new - spec files. +## 5. Implementation Plan + +### Phase 1: Playwright Tests (spec behavior, `test.fixme`) +New spec `tests/e2e/notifications-webpush.spec.ts`: +- Provision Web Push provider from the Notifications page. +- Subscribe this device (mocking `PushManager`/`Notification.requestPermission` + via Playwright's browser context, since real push delivery cannot be + exercised in CI). +- Unsubscribe removes the device from the subscriptions list. +- Per-event-type toggles persist for a `webpush` provider row identically + to an existing type (regression coverage that the generic preference UI + still works for the new type). +All `test.fixme` until Phase 4. + +### Phase 2: Backend Implementation +- `go.mod` bump (§3.7). +- `models.WebPushSubscription` + migration registration (§3.3). +- `notify_providers_import.go` blank import. +- Allowlist wiring: `isSupportedNotificationProviderType`, + `isDispatchEnabled` + `FlagWebPushServiceEnabled`, + `supportsJSONTemplates` (§2.2). +- `notify_webpush_adapter.go`: `dispatchWebPushViaNotify`, + `extractHTTPStatusFromNotifyError` (§3.5). +- `WebPushHandler` (new, `internal/api/handlers/webpush_handler.go`): + `Provision`, `VAPIDPublicKey`, `Subscribe`, `ListSubscriptions`, + `Unsubscribe` (§3.4). +- `NotificationService` additions: `ProvisionWebPush`, + `GetWebPushVAPIDPublicKey`, `RegisterWebPushSubscription`, + `ListWebPushSubscriptionsForUser`, `DeleteWebPushSubscription` + (singleton-check, validation per §3.2/3.4). +- Routes wired in `routes.go` under `management` group. +- Unit tests for every new function; `notification_service_registry_consistency_test.go` + gains `"webpush"`. + +### Phase 3: Frontend Implementation +- `frontend/public/sw.js` (§3.6.1). +- `notifications.ts` additions (§3.6.2). +- `Notifications.tsx` UI additions (§3.6.3), including the + `urlBase64ToUint8Array` helper (new, colocated or in a small + `src/utils/webpush.ts`). +- Vitest unit tests: API client functions, `urlBase64ToUint8Array`, + and component tests for the provision/subscribe/unsubscribe UI states + (mocking `navigator.serviceWorker`/`PushManager`/`Notification`, none of + which exist in jsdom by default — test setup must stub them). + +### Phase 4: Integration and Testing +- Un-`fixme` the Phase 1 E2E spec; run `npx playwright test + tests/e2e/notifications-webpush.spec.ts --project=firefox`. +- `./scripts/scan-gorm-security.sh --check` (new model/migration — mandatory + per CLAUDE.md §1.5). +- `scripts/go-test-coverage.sh` / `scripts/frontend-test-coverage.sh` ≥ 85%. +- `lefthook run pre-commit`, `make lint-fast`. +- CodeQL Go/JS locally (new feature surface, per CLAUDE.md §3 "run locally + when the change adds a new feature"). + +### Phase 5: Documentation and Deployment +- `docs/features.md`: add Web Push to the notification-provider list. +- New `docs/features/notifications-webpush.md` (or a section in the + existing notifications doc, whichever `docs-writer` finds already + structured): user-facing walkthrough — enabling, per-device subscribe, + troubleshooting ("no prompt appeared" → browser permission blocked at + the OS/browser level, outside Charon's control). +- `ARCHITECTURE.md`: update the `go_notify_yourself` technology-stack row + to mention Web Push alongside the existing provider list; note the new + `WebPushSubscription` table under whatever section lists persistent + models, if one exists. --- -### Commit 2 — Part A: enforce admin authorization on CrowdSec admin routes (advisory fix) - -- **Type:** `fix(security): tighten authorization checks on privileged API routes` -- **Scope:** - - `routes.go`: declare `managementAdmin := management.Group("/"); .Use(RequireRole(admin))`; - change `crowdsecHandler.RegisterRoutes(management)` → `(managementAdmin)`. - - Frontend companion guard: wrap `security/crowdsec` route in - `` (`App.tsx`); gate the `navigation.crowdsec` - nav child with `user?.role === 'admin'` (`Layout.tsx`). - - `routes_test.go`: `TestRegister_CrowdsecAdminRoutesRequireAdminRole` (§3.1.4); - a handler-level 403 assertion in `crowdsec_handler_test.go` if lightweight. -- **Files:** `backend/internal/api/routes/routes.go`, - `backend/internal/api/routes/routes_test.go`, - `backend/internal/api/handlers/crowdsec_handler_test.go` (maybe), - `frontend/src/App.tsx`, `frontend/src/components/Layout.tsx`, - `frontend/src/components/__tests__/Layout.test.tsx` (nav-gating assertion) or - a new small `App` route test. -- **Depends on:** Commit 1 (ordering). -- **Validation gate:** - `cd backend && go build ./... && go test ./internal/api/routes/... ./internal/api/handlers/...`; - new test proves unauth→401 / `role=user`→403 / `role=admin`→not-403 on the 4 - representative routes; existing `TestRegister_AllRoutesRegistered` / - `TestRegister_CrowdSecRoutes` still pass (paths unchanged); - `cd frontend && npm run type-check && npx vitest run src/components/__tests__/Layout.test.tsx`; - `make lint-fast`; staticcheck clean. +## 6. Acceptance Criteria + +1. `go.mod` pins `go_notify_yourself v0.3.0`; `go build ./...` succeeds. +2. Admin can provision a Web Push provider with zero manual key entry; + a second provision attempt — sequential **or concurrent** (racing the + `idx_webpush_singleton` partial unique index, §3.3.4) — is rejected with + `409`, never `500`, and never results in two `webpush` provider rows. +3. An authenticated browser can subscribe and receive a real push + notification end-to-end in manual testing (documented in the PR + description as a manual verification step, since CI cannot receive a + real browser push). +4. Unsubscribing removes the row and stops further delivery to that + device. +5. A subscription that a push service reports as 404/410 is + auto-pruned on next dispatch; a subscription failing for other reasons + survives up to 9 consecutive failures before being pruned as presumed-dead. +6. Every other provider type's tests still pass unmodified (no regression). +7. `notification_service_registry_consistency_test.go` passes with + `"webpush"` included. +8. Targeted Playwright spec passes on `--project=firefox`. +9. `./scripts/scan-gorm-security.sh --check` reports zero CRITICAL/HIGH. +10. Backend and frontend coverage both ≥ 85%. +11. `make lint-fast` / staticcheck clean; `npm run type-check` clean. +12. `docs/features.md` and `ARCHITECTURE.md` updated. --- -### Commit 3 — Part B: deny-by-default authorization across the management group - -- **Type:** `fix(security): apply deny-by-default authorization on management API subroutes` -- **Scope:** - - Re-run the route audit vs HEAD; reconcile with §3.2.2. - - `RegisterRoutes(read, admin *gin.RouterGroup)` split (C1/C2): `HecateHandler` - (reads `GET /hecate/status|/tunnels|/tunnels/:uuid` on `read`, rest on - `admin`); `OrthrusHandler` (reads `GET /orthrus/agents|/agents/:uuid` on - `read`, rest incl. `/snippets`, `/proxy-status` on `admin`); - `RemoteServerHandler` (reads `GET /remote-servers|/remote-servers/:uuid` on - `read`, rest incl. `/test` on `admin`). - - `SecurityHeadersHandler` (C6): **delete** `RegisterRoutes`; register its ~11 - routes inline in `routes.go` — reads + 3 calculator `POST`s on `management`, - profile `POST/PUT/DELETE` + `presets/apply` on `managementAdmin`. - - `MOVE → managementAdmin`: `GET /audit-logs`, `GET /audit-logs/:uuid` (C4), - `GET /dns-providers/:id/audit-logs` (C5); `adminEncryption` group decl → - `managementAdmin.Group("/admin/encryption")`. - - ADMIN-ARG (per-route `middleware.RequireRole(models.RoleAdmin)` 2nd arg): - plugin enable/disable/reload; dns-provider mutations + `POST /dns-providers/test` - + `POST /dns-providers/:id/test` (C7) + credential + `POST /dns-providers/detect`; - `ManualChallengeHandler.RegisterRoutes(managementAdmin)`; certificate - mutations incl. `/export`; access-list mutations; domain mutations; settings - mutations (+ keep existing `GET /settings/smtp` arg); `PUT /feature-flags`; - `POST /system/permissions/repair`; - `POST /notifications/providers/test` + `/providers/preview` - + `/external-templates/preview` (C3). - - Frontend companion `RequireRole` guards + nav filters: - `/security/audit-logs` (route only — no nav entry), `/hecate/agent` - (route + nav child), `/security/encryption` (route + nav child). - Do **not** guard `navigation.hecate` wholesale or `/hecate/tunnels`. - - `TestManagementGroup_MutationsAreAdminGuarded` + `USER_OK_MUTATION_ALLOWLIST` - + `PUBLIC_MUTATION_ALLOWLIST` (reviewed constants). -- **Files:** `backend/internal/api/routes/routes.go` (the ~35 sites in the - table), `backend/internal/api/handlers/security_headers_handler.go` - (delete `RegisterRoutes` method + its test that asserted the old group), - `backend/internal/api/handlers/hecate_handler.go` / `orthrus_handler.go` / - `remote_server_handler.go` (`RegisterRoutes(read, admin)` signature + - callers), `backend/internal/api/routes/routes_test.go`, any handler test that - assumed a now-moved route was reachable by `role=user` - (`hecate_handler_test.go`, `orthrus_handler_test.go`, - `audit_log_handler_test.go`, `notification_provider_handler_test.go`), - `frontend/src/App.tsx`, `frontend/src/components/Layout.tsx`, related frontend - tests. -- **Depends on:** Commit 2 (`managementAdmin`). -- **Validation gate:** `go build ./... && go test ./...` (full — catches handler - tests broken by moves); new enforcement test green; manual diff of - `router.Routes()` inventory before/after (path set unchanged, only middleware - chains differ); `cd frontend && npm run type-check && npx vitest run` (touched - suites); `make lint-fast`; staticcheck clean. - ---- +## 7. Risks and Open Questions -### Commit 4 — Part C: remove the public registration endpoint - -- **Type:** `fix(security): reduce unauthenticated API surface` -- **Scope:** - - Delete `api.POST("/auth/register", …)` (`routes.go:295`), - `AuthHandler.Register`, `RegisterRequest` (`auth_handler.go`). Drop - now-unused imports. - - **Keep** `AuthService.Register` (+ `count==0 → RoleAdmin`); add a doc - comment marking it internal/test-only. - - Update `routes_test.go` refs (`:162` remove from `expectedRoutes`; `:215` - remove allowlist entry; `:335` → `assert.NotContains`); delete - `TestAuthHandler_Register_InvalidJSON` in `additional_coverage_test.go`; - switch `crowdsec_lapi_integration_test.go` `authenticate()` helper to - `POST /api/v1/setup`. - - New `TestRegister_PublicRegistrationEndpointRemoved` (§3.3.4) covering - route-gone + `/setup` bootstrap + email-invite acceptance. -- **Files:** `backend/internal/api/routes/routes.go`, - `backend/internal/api/handlers/auth_handler.go`, - `backend/internal/services/auth_service.go` (doc comment only), - `backend/internal/api/routes/routes_test.go`, - `backend/internal/api/handlers/additional_coverage_test.go`, - `backend/integration/crowdsec_lapi_integration_test.go`. -- **Depends on:** Commit 2 (shares `routes_test.go` allowlist edits — sequence - after B to avoid churn). -- **Validation gate:** `go build ./...` (+ `-tags integration` compile check for - the integration file); `go test ./internal/api/...`; `staticcheck` / `go vet` - clean (no dangling refs); `AuthService.Register` unit tests unchanged & green; - `make lint-fast`. +| # | Risk | Mitigation / Note | +|---|---|---| +| 1 | **No typed status error from `transport.Wrapper.Send`** — 404/410 detection relies on regex-parsing a formatted error string (`"provider returned status %d..."`) that upstream could reword without a major version bump (it's not part of any documented stable contract). | `extractHTTPStatusFromNotifyError` is isolated to one function with its own unit tests asserting the exact current string shape; if it ever stops matching, the failure mode is "subscriptions never auto-prune, `FailureCount` accumulates and prunes at 10" (safe-ish degradation, not silent data loss) rather than a crash. **Suggest filing an upstream issue against `go_notify_yourself` requesting a typed `transport.StatusError` with a `StatusCode` field** — out of scope for this PR but worth raising given Web Push is the first provider where distinguishing status codes actually matters to the caller. | +| 2 | **VAPID key rotation is unsupported in this PR.** If an admin wants to rotate/regenerate the keypair (e.g. suspected key compromise), there is no endpoint for it — only initial provisioning. Re-running provision is blocked by the 409 singleton check. | Documented non-goal (§1.3). A rotation endpoint would need to also cascade-delete every existing `WebPushSubscription` (per §2.1's confirmed invariant: rotating invalidates every subscriber) and prompt every device to re-subscribe — enough additional surface (confirmation UX, cascade semantics) to warrant its own follow-up spec rather than folding it into this PR. | +| 3 | **Sequential-per-subscription dispatch (§3.5) has no concurrency cap tuning.** A provider with a very large number of subscriptions serializes all sends behind one goroutine, so total dispatch latency for that event scales linearly with subscription count. | Acceptable for Charon's expected scale (a handful of admin browsers per self-hosted instance, not a multi-tenant SaaS fan-out) — explicitly a self-hosted, novice-admin-focused tool per `ARCHITECTURE.md`'s stated audience. Flagged, not silently assumed away. | +| 4 | **No admin-wide view of all users' subscriptions** — `GET .../subscriptions` is scoped to the caller only (§3.4.4). An admin cannot see "3 other users have devices subscribed" from the UI. | Deliberate scope cut for this PR (§1.3); a follow-up could add an admin-only `GET .../subscriptions/all` if that visibility is requested. | +| 5 | **RESOLVED by user decision (no longer open)**: non-admin authenticated users with management access (`RoleUser`, not `RolePassthrough`) may self-service subscribe/list/unsubscribe their own Web Push destination and read the VAPID public key — no admin gate on those four routes. Provisioning stays admin-only. See §3.4.0 for the full decision writeup and how it interacts with the four security-event `NotifyXxx` toggles (closed via existing `Update`-endpoint admin gate, confirmed by reading the handler — no new code required). | Decision made; §3.4/§3.4.0 updated to match. No further action beyond the regression test called out in §3.4.0 (commit 6, §9). | +| 6 | **`go.sum` transitive-dependency diff unverified** (§3.7) — spec inspected only the webpush package's own imports, not a full `go mod tidy` diff. | Explicit implementation-time verification step called out in §3.7 and Phase 2; not assumed clean. | +| 7 | **VAPID private key is stored in plaintext at rest** — `NotificationProvider.Token` (§3.2) holds `VAPIDPrivateKey` unencrypted in SQLite, same as all 8 other provider types' bearer tokens/webhook URLs (`grep` across `internal/models` confirms **no** `Token`-shaped field in this codebase is currently encrypted at rest — this is existing practice, not a regression introduced by this PR, so it is **not a blocker** here). It is, however, a materially different class of secret than a bearer token or webhook URL: compromise of the VAPID private key lets an attacker forge and send arbitrary push messages, indefinitely, to every subscriber of that key (every browser that ever ran `PushManager.subscribe` against this instance's public key) — not just abuse one destination the way a leaked Gotify/ntfy token would. Charon already has a stronger pattern available and in production use for exactly this class of problem: `internal/crypto.EncryptionService` (AES-256-GCM), currently used for `DNSProviderCredential.CredentialsEncrypted` (`backend/internal/models/dns_provider_credential.go:22`, `backend/internal/models/dns_provider.go:26`) — this feature does not use it, consistent with (not worse than) every other provider token today. | **Documented, non-blocking for this PR.** Flagging a **future hardening pass across all `NotificationProvider.Token` values** (not scoped to webpush specifically — encrypting only the VAPID key while leaving Gotify/Telegram/Slack/Pushover/ntfy tokens in plaintext would be an inconsistent half-measure and would need its own migration/key-management design either way) as a follow-up, out of scope for this PR. Not scoping encryption into this PR's Commit Slicing Strategy (§9). | --- -### Commit 5 — Enable E2E, coverage, docs - -- **Type:** `docs: document management-API authorization model and account-creation flow` -- **Scope:** - - Un-`fixme` the Commit 1 specs; adjust selectors/fixtures to the shipped - behavior; run targeted specs (firefox). - - File two follow-up issues (out of scope here): (1) "per-IP rate limit / - throttle middleware for `/api/v1/auth/*`" (`/auth/register` removed, - `/auth/login` already has account lockout); (2) "hide disabled - create/edit/delete controls for `role=user` on READ-classified admin pages - (Access Lists, Certificates, DNS Providers, Security Headers, Domains, - Remote Servers, Hecate tunnels)" — cosmetic; the API already returns `403` - (spec §7 item 1). - - Docs: `ARCHITECTURE.md` (Security Architecture / Auth & Authorization — - `managementAdmin` boundary; no public self-registration; bootstrap + - invite model), `SECURITY.md` (Authentication & Authorization section), - `docs/security.md`, `docs/features/access-control.md`, `docs/features.md`, - `docs/features/crowdsec.md`, `docs/features/custom-plugins.md` / - `plugin-security.md`. -- **Files:** the Commit 1 spec files (remove `fixme`); the docs listed above. -- **Depends on:** Commits 2-4. -- **Validation gate (full DoD):** - `npx playwright test crowdsec-admin-authz authorization-rbac public-registration-removed auth-api-enforcement --project=firefox` all green; - `bash scripts/local-patch-report.sh` (artifacts present, patch coverage green); - `lefthook run pre-commit` (CodeQL Go+JS) 0 high/critical; `make trivy` clean; - `make lint-fast` + `make lint-backend` clean; - `scripts/go-test-coverage.sh` ≥ 85 %; `scripts/frontend-test-coverage.sh` ≥ 85 %; - `cd frontend && npm run type-check && npm run build`; - `cd backend && go build ./...`; `go test ./...` + `npx vitest run` zero - failures; debug/print cleanup. +## 8. Definition of Done Checklist (repeated from CLAUDE.md, for the implementation phase) ---- - -### Rollback & contingency (PR-wide) - -- **Per-commit revert:** Commits 2, 3, 4 are individually revertable. - - Revert **Commit 3** alone if the Part B sweep regresses a `role=user` - workflow found late — Commit 2 (the actual advisory fix) and Commit 4 still - stand and ship value. - - Revert **Commit 4** alone (restore the register route) without affecting - the authz fixes, if an external consumer of `/auth/register` is discovered - that can't migrate to `/setup` in time — though the advisory title itself - frames public registration as the root enabler, so this should be a last - resort with a tracking issue. -- **Minimum shippable:** Commits 1-2 + docs = the advisory is closed. Parts B/C - can be dropped from the PR (update this spec + PR description) if they need - more time — but the intent is to land all three together. -- **No migration to roll back** — zero schema changes. -- **Feature-flag option (contingency, not in the default plan):** if reviewers - want a kill switch for Part C rather than a hard delete, gate the register - route behind a `Setting` (`auth.public_registration_enabled`, default - `false`) instead of removing it. Adds surface; only if explicitly requested. -- **Embargo:** keep the GHSA id, "CrowdSec", route paths, and - "authorization bypass / public registration" out of every commit subject and - any changelog-visible line. The PR description MAY reference the advisory - (repo private, pre-disclosure) — confirm with the maintainer before opening. +- [ ] Targeted Playwright spec, `--project=firefox`, passes. +- [ ] `./scripts/scan-gorm-security.sh --check` — zero CRITICAL/HIGH. +- [ ] `bash scripts/local-patch-report.sh` artifacts produced. +- [ ] CodeQL Go/JS + Trivy run locally (new feature surface). +- [ ] `lefthook run pre-commit` clean. +- [ ] `make lint-fast` / staticcheck clean. +- [ ] Backend + frontend coverage ≥ 85%. +- [ ] `npm run type-check` clean. +- [ ] `go build ./...` and `npm run build` succeed. +- [ ] All existing + new unit tests pass. +- [ ] No debug prints/dead code left behind. --- -## 10. Commit Message Conventions (per `CLAUDE.md`) - -- Security-relevant commits use `fix(security):` with a **deliberately vague** - subject — category of issue + category of mitigation only. Never name the - vulnerability class, the component ("CrowdSec", "plugins"), the attack vector - ("public registration"), or any route path. - - Commit 2: `fix(security): tighten authorization checks on privileged API routes` - - Commit 3: `fix(security): apply deny-by-default authorization on management API subroutes` - - Commit 4: `fix(security): reduce unauthenticated API surface` -- Non-security commits: `test:` (Commit 1), `docs:` (Commit 5). -- `fix:` triggers Docker builds (intended here). -- Every commit message ends with: - ``` - Claude-Session: https://claude.ai/code/session_01Wm1jzKSdvz2LCusQC2qokM - ``` -- PR description ends with: - ``` - https://claude.ai/code/session_01Wm1jzKSdvz2LCusQC2qokM - ``` - ---- +## 9. Commit Slicing Strategy -## 11. Handoff - -- Next: `supervisor` review of this spec → iterate → user approval → implement - Commits 1-5 in order via `backend-dev` / `frontend-dev` (each commit passes - its gate before the next starts) → `supervisor` implementation review → - `qa-security` audit last → `docs-writer`. -- Key references for implementers: - - Advisory root cause: `backend/internal/api/routes/routes.go:838`, `:373-374`; - correct pattern at `:796-797` and `:1011-1012`; per-route arg precedent at - `:457`. - - `backend/internal/api/middleware/auth.go` (`RequireRole`, - `RequireManagementAccess`). - - `backend/internal/api/handlers/permission_helpers.go` (`requireAdmin`, - `isAdmin`). - - Part C targets: `backend/internal/api/handlers/auth_handler.go:238-256` - (delete `RegisterRequest` + `Register`), `routes.go:295` (delete route), - `backend/internal/services/auth_service.go:31` (**keep**), - `backend/internal/api/handlers/user_handler.go:141` (`Setup` — the retained - bootstrap path). - - Test refs to fix: `routes_test.go:162,215,335`; - `additional_coverage_test.go:717-732`; - `backend/integration/crowdsec_lapi_integration_test.go:52-59`. - - Existing email-invite (the supported post-bootstrap path, unchanged): - `backend/internal/api/handlers/user_handler.go` (`InviteUser` / `ValidateInvite` - / `AcceptInvite`), `backend/internal/models/user.go` (invite fields), - `frontend/src/pages/AcceptInvite.tsx`, `frontend/src/api/users.ts`. - - Frontend gating pattern to mirror: `frontend/src/components/RequireRole.tsx`, - `frontend/src/App.tsx:120,126`, `frontend/src/components/Layout.tsx:127`. - - Test harness: `backend/internal/api/routes/routes_test.go` - (`TestRegister_*`, `materializeRoutePath`, `publicMutationAllowlist`), - `tests/security-enforcement/authorization-rbac.spec.ts` - (`loginAndGetToken`, `TEST_USERS`). +**Decision: single PR, one feature ("Web Push notification provider"), +delivered as the ordered sequence of logical commits below. No PR +splitting** (per CLAUDE.md "Commit Slicing & PR Strategy" — backend, +frontend, and hardening all land in one PR, reviewed together). + +| # | Commit | Scope / Files | Depends on | Validation gate | +|---|---|---|---|---| +| 1 | `test: add e2e specs for web push subscribe/unsubscribe flow (fixme)` | `tests/e2e/notifications-webpush.spec.ts` (all `test.fixme`) | — | Spec file parses/lints; no assertions run yet | +| 2 | `chore: bump go_notify_yourself to v0.3.0` | `backend/go.mod`, `backend/go.sum` | — | `go build ./...`, `go mod verify`, `go.sum` diff reviewed for unexpected transitive deps (§7 risk 6) | +| 3 | `feat: add WebPushSubscription model, migration, and singleton index` | `backend/internal/models/webpush_subscription.go` (+ test), `backend/internal/api/routes/routes.go` (AutoMigrate line **and** the new `idx_webpush_singleton` partial-unique-index `db.Exec`, §3.3.4) | 2 | `go build ./...`; `go test ./internal/models/...`; `./scripts/scan-gorm-security.sh --check`; **new**: concurrency test asserting two simultaneous `INSERT`s racing the index produce exactly one success and one unique-constraint-violation error (§3.3.4) | +| 4 | `feat: wire webpush into notify provider allowlist` | `notify_providers_import.go`, `notification_service.go` (`isSupportedNotificationProviderType`, `isDispatchEnabled`, `supportsJSONTemplates`), `notification_feature_flags.go` (`FlagWebPushServiceEnabled`), `notification_service_registry_consistency_test.go` | 2 | `go test ./internal/services/... -run Registry` | +| 5 | `feat: add web push dispatch fan-out and subscription pruning` | `internal/services/notify_webpush_adapter.go` (+ test: fan-out, 404/410 pruning, failure-count threshold, `extractHTTPStatusFromNotifyError`) | 3, 4 | `go test ./internal/services/...`; coverage on new file ≥ 85% | +| 6 | `feat: add web push provisioning and subscription API endpoints` | `internal/api/handlers/webpush_handler.go` (+ test), `NotificationService` additions (`ProvisionWebPush` — including the constraint-violation → `409` mapping on `CreateProvider`, §3.1/§3.4.1 — plus `GetWebPushVAPIDPublicKey`, `RegisterWebPushSubscription`, `ListWebPushSubscriptionsForUser`, `DeleteWebPushSubscription`), `routes.go` route registration | 3, 4, 5 | `go test ./internal/api/handlers/...`; `go build ./...`; **new**: test asserting a `409` (not `500`) response when `CreateProvider`'s `INSERT` fails on `idx_webpush_singleton`; **new**: regression test asserting `RoleUser` gets `403` from `PUT /notifications/providers/:id` when setting a `NotifySecurityXxx` field on a `Type: "webpush"` row (§3.4.0) | +| 7 | `feat: add web push service worker and subscribe/unsubscribe UI` | `frontend/public/sw.js`, `frontend/src/api/notifications.ts`, `frontend/src/utils/webpush.ts` (new helper), `frontend/src/pages/Notifications.tsx` (+ Vitest tests) | 6 | `npm run type-check`; `npm test` (Vitest); coverage ≥ 85% | +| 8 | `test: enable web push e2e specs` | Un-`fixme` `tests/e2e/notifications-webpush.spec.ts` | 6, 7 | `npx playwright test tests/e2e/notifications-webpush.spec.ts --project=firefox` passes | +| 9 | `docs: document web push notification provider` | `docs/features.md`, `docs/features/notifications-webpush.md` (or existing notifications doc section), `ARCHITECTURE.md` | 8 | Docs review only; no code gate | + +Each commit builds and passes its own gate before the next starts, per +CLAUDE.md's "Per-Commit Requirement." The PR as a whole must pass the full +Definition of Done (§8) before merge. + +### Rollback / contingency (PR-wide) + +- **Pre-merge**: any commit's validation gate failing blocks progression to + the next commit in the sequence — implementation halts and the failing + commit is fixed in place (new commit, never force-amend, per CLAUDE.md + git safety rules) before continuing. +- **Post-merge regression**: revert is safe and self-contained — the new + `WebPushSubscription` table and the `webpush` provider-type branch are + fully additive; no existing provider type's code path, schema, or + dispatch logic is modified by this feature (confirmed throughout §2.2: + every existing switch/map gains a new case, none of the existing cases + change). A `git revert` of the merge commit removes the feature cleanly; + the only residual state is the `WebPushSubscription` table and any + provisioned `webpush` `NotificationProvider` row left in the database, + which are inert (no code references them post-revert) and can be cleaned + up via a follow-up migration if desired, but pose no correctness risk if + left in place. +- **Partial rollback within the PR is not applicable** — per CLAUDE.md, one + feature merges as one PR or not at all; there is no supported "merge + commits 1-6 but not 7-9" state. From d9d4df2e75148c175ed8f779896646bfe61aacd7 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Tue, 15 Sep 2026 06:51:35 +0000 Subject: [PATCH 09/13] docs: document web push notification provider Adds Web Push to the notification-provider list and feature summary, walks through admin provisioning and per-device subscribing in docs/features/notifications.md, and notes the new WebPushSubscription model and provider entry in ARCHITECTURE.md. Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN --- ARCHITECTURE.md | 5 +++-- docs/features.md | 2 +- docs/features/notifications.md | 39 +++++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 91d314010..a590006f1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -130,7 +130,7 @@ graph TB | **WebSocket** | gorilla/websocket | Latest | Real-time log streaming | | **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption | | **Metrics** | Prometheus Client | Latest | Application metrics | -| **Notifications** | github.com/Wikid82/go_notify_yourself | Current | External delivery-engine module (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, and email) consumed via Charon-supplied SSRF/SMTP/template adapters — see Service Layer below | +| **Notifications** | github.com/Wikid82/go_notify_yourself | Current | External delivery-engine module (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, Web Push, and email) consumed via Charon-supplied SSRF/SMTP/template adapters — see Service Layer below | | **Docker Client** | Docker SDK | Latest | Container discovery | | **Logging** | Logrus + Lumberjack | Latest | Structured logging with rotation | | **Backup Archive Encryption** | filippo.io/age | Latest | Passphrase (scrypt) encryption of backup archives; audited, pure Go, streaming AEAD — avoids buffering whole archives in RAM or hand-rolling chunked AES-GCM | @@ -347,7 +347,8 @@ fork/offline fallback. - **CertificateService:** ACME certificate provisioning and renewal - **DockerService:** Container discovery and monitoring - **MailService:** SMTP transport and branded HTML templates for certificate-expiry and other system emails -- **NotificationService:** GORM CRUD for providers/templates, event-type-to-provider routing, and feature-flag gating (`internal/services/notification_service.go`); outbound dispatch for all seven provider types (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook) plus email is delegated to the external `github.com/Wikid82/go_notify_yourself` module (`v0.2.0+`) through three Charon-supplied adapters — `notify_client_adapter.go` (SSRF-safe HTTP client/URL validation, wired to `internal/network`/`internal/security`), `notify_provider_adapter.go`, and `notify_email_adapter.go` (wraps `MailService` behind the module's `Mailer`/`TemplateRenderer` interfaces). `notify_provider_adapter.go`'s `buildNotifySender` maps a `NotificationProvider` row into a `map[string]any` config (`providerConfigMap`) and constructs the `Sender` by calling the module's self-registering provider factory registry (`notify.New(provider.Type, config)`) rather than a hardcoded per-provider switch/constructor call — `notify_providers_import.go` hand-picks the blank imports (`providers/discord`, `providers/slack`, `providers/gotify`, `providers/pushover`, `providers/ntfy`, `providers/telegram`, `providers/webhook`, `providers/email`) that register those factories at `init()` time, deliberately not importing `providers/all`. Charon's own supported-provider allowlist (`isSupportedNotificationProviderType`, `notification_service.go`) remains independently hardcoded and is not derived from the registry; a unit test asserts it stays a subset of `notify.RegisteredTypes()`. The formerly in-repo delivery engine (`internal/notifications/`) has been removed. +- **NotificationService:** GORM CRUD for providers/templates, event-type-to-provider routing, and feature-flag gating (`internal/services/notification_service.go`); outbound dispatch for all eight provider types (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, Web Push) plus email is delegated to the external `github.com/Wikid82/go_notify_yourself` module (`v0.3.0+`) through three Charon-supplied adapters — `notify_client_adapter.go` (SSRF-safe HTTP client/URL validation, wired to `internal/network`/`internal/security`), `notify_provider_adapter.go`, and `notify_email_adapter.go` (wraps `MailService` behind the module's `Mailer`/`TemplateRenderer` interfaces). `notify_provider_adapter.go`'s `buildNotifySender` maps a `NotificationProvider` row into a `map[string]any` config (`providerConfigMap`) and constructs the `Sender` by calling the module's self-registering provider factory registry (`notify.New(provider.Type, config)`) rather than a hardcoded per-provider switch/constructor call — `notify_providers_import.go` hand-picks the blank imports (`providers/discord`, `providers/slack`, `providers/gotify`, `providers/pushover`, `providers/ntfy`, `providers/telegram`, `providers/webhook`, `providers/webpush`, `providers/email`) that register those factories at `init()` time, deliberately not importing `providers/all`. Charon's own supported-provider allowlist (`isSupportedNotificationProviderType`, `notification_service.go`) remains independently hardcoded and is not derived from the registry; a unit test asserts it stays a subset of `notify.RegisteredTypes()`. The formerly in-repo delivery engine (`internal/notifications/`) has been removed. + - **Web Push** is the one provider type that isn't a single destination: one `NotificationProvider` row (`Type = "webpush"`, a DB-enforced singleton) holds the shared VAPID application identity, while each subscribed browser/device is a row in a separate `WebPushSubscription` model (`internal/models/webpush_subscription.go`) — FK'd to that provider row, with its own `notify_webpush_adapter.go` fan-out path that sends to every subscription individually and auto-prunes rows the push service reports as gone. - **SettingsService:** Application settings management - **BackupService:** Format-v2 archive creation (manifest + SHA-256 checksums), configurable cron scheduling, the safe-restore pipeline (validate → pre-restore safety backup → apply → reconcile), and optional age/scrypt archive encryption — see "Backup & Restore Subsystem" below diff --git a/docs/features.md b/docs/features.md index 7704efa1d..ed8ebdd88 100644 --- a/docs/features.md +++ b/docs/features.md @@ -311,7 +311,7 @@ See exactly how your sites are performing — right from the home screen. The Da ### 🔔 Notifications -Get alerted when it matters. Charon sends notifications through Discord, Gotify, Ntfy, Pushover, Slack, Email, and Custom Webhook providers. Choose a built-in JSON template or write your own to control exactly what your alerts look like. +Get alerted when it matters. Charon sends notifications through Discord, Gotify, Ntfy, Pushover, Slack, Telegram, Email, Custom Webhook, and Web Push providers. Choose a built-in JSON template or write your own to control exactly what your alerts look like, or turn on Web Push for alerts that land right on your phone or computer — no extra app required. → [Learn More](features/notifications.md) diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 9166d18b4..a8d926ae0 100644 --- a/docs/features/notifications.md +++ b/docs/features/notifications.md @@ -20,7 +20,9 @@ Notifications can be triggered by various events: | **Gotify** | ✅ Yes | ✅ HTTP API | ✅ Priority + Extras | | **Pushover** | ✅ Yes | ✅ HTTP API | ✅ Priority + Sound | | **Ntfy** | ✅ Yes | ✅ HTTP API | ✅ Priority + Tags | +| **Telegram** | ✅ Yes | ✅ Bot API | ✅ Rich Text | | **Custom Webhook** | ✅ Yes | ✅ HTTP API | ✅ Template-Controlled | +| **Web Push** | ✅ Yes | ✅ Browser Push | ✅ Native OS/Browser Notifications | | **Email** | ❌ No | ✅ SMTP | ✅ HTML Branded Templates | Additional providers are planned for later staged releases. @@ -306,9 +308,44 @@ Ntfy delivers push notifications to your phone or desktop using a simple HTTP-ba - `4` - High - `5` - Max (urgent) +### Web Push + +Web Push sends alerts straight to your phone or computer as a native notification — the same kind of pop-up you get from any app — with nothing to install and no third-party account to sign up for. It works right in your browser. + +Web Push has two steps: an administrator turns it on for the whole Charon instance (once), and then each person subscribes their own individual devices. + +#### Step 1: Turn on Web Push (admin, once) + +1. Go to **Settings** → **Notifications** +2. Find the **Web Push** card +3. Enter a **Name** (any label you like, e.g. "Browser Push") +4. Enter a **Contact URI** — this is just a contact address (an email like `mailto:admin@example.com`, or a webpage like `https://example.com`) that the browser's push service may use to reach you if something goes wrong. It's not shown to anyone else. +5. Click **Provision Web Push** + +That's it — Web Push is now available for everyone with a Charon account to subscribe to. + +> **Note:** This only needs to happen once. There's currently no way to regenerate these keys later — doing so would silently disconnect every device that has already subscribed — so treat this as a one-time setup step rather than something to redo. + +#### Step 2: Subscribe your device (every user, on every device) + +Once Web Push is turned on, anyone with a Charon account — not just administrators — can subscribe their own browser: + +1. Go to **Settings** → **Notifications** +2. In the **Web Push** card, click **Enable push notifications on this device** +3. Your browser will show a permission prompt — click **Allow** +4. Your device now appears in the **Subscribed devices** list, and you'll start receiving alerts there + +**What "per-device" means:** each browser you enable this on is its own independent subscription. If you enable it on your phone's browser and your laptop's browser, both receive alerts — turning one off doesn't affect the other. If you switch to a different browser, or clear your browser's site data, that subscription is gone and you'll need to click **Enable push notifications on this device** again from the new browser. + +To stop receiving alerts on a device, find it in the **Subscribed devices** list and click the trash icon next to it. + +#### Troubleshooting: no permission prompt appeared + +If clicking **Enable push notifications on this device** doesn't show a permission prompt, your browser or operating system is blocking notifications at the system level — this happens outside Charon and isn't something Charon controls. Check your browser's site settings and your operating system's notification settings to make sure notifications aren't already blocked for this site. + ## Planned Provider Expansion -Additional providers (for example Telegram) are planned for later staged +Additional providers are planned for later staged releases. This page will be expanded as each provider is validated and released. ## Template Variables From bba336c59e9655284c77a178c2a02828e9d325dd Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Tue, 15 Sep 2026 07:04:39 +0000 Subject: [PATCH 10/13] test: enable web push e2e specs Flip the Web Push provider E2E specs from test.fixme to live tests now that the backend/frontend implementation has landed, and adapt them to the actual UI: provisioning and device subscribe/unsubscribe happen through a dedicated Web Push card rather than the generic Add Provider form, and the device toggle is a subscribe button plus a per-row remove button rather than a single on/off control. Claude-Session: https://claude.ai/code/session_01YMic1SfEixL9Ej3RvnDLFN --- tests/e2e/notifications-webpush.spec.ts | 213 +++++++++++++++--------- 1 file changed, 133 insertions(+), 80 deletions(-) diff --git a/tests/e2e/notifications-webpush.spec.ts b/tests/e2e/notifications-webpush.spec.ts index 2d6b5b29e..c113a624f 100644 --- a/tests/e2e/notifications-webpush.spec.ts +++ b/tests/e2e/notifications-webpush.spec.ts @@ -1,16 +1,31 @@ /** * Web Push Notification Provider E2E Tests * - * Phase 1 (docs/plans/current_spec.md §5, §9 commit 1): encodes the intended + * Phase 1 (docs/plans/current_spec.md §5, §9 commit 1) encoded the intended * Web Push provider behavior against the API contracts (§3.4) and frontend - * design (§3.6) before any backend/frontend code for this feature exists. - * All tests below are `test.fixme` and are flipped to live tests in commit 8 - * (§9), once commits 2-7 (backend model/dispatch/handlers, frontend service + * design (§3.6) as `test.fixme` specs, before any backend/frontend code for + * this feature existed. Commit 8 (§9) flips them to live tests now that + * commits 2-7 (backend model/dispatch/handlers, frontend service * worker/API client/UI) have landed. * + * The API contracts (§3.4) landed exactly as specified — same routes + * (`backend/internal/api/routes/routes.go`), same request/response shapes + * (`backend/internal/api/handlers/webpush_handler.go`, + * `frontend/src/api/notifications.ts`). + * + * The frontend UI (`frontend/src/pages/Notifications.tsx`) landed with one + * deliberate deviation from the §3.6 sketch: rather than provisioning + * through the generic "Add Provider" form (which has no way to select + * `webpush` as a type — it's a singleton, never a user choice), the + * implementation adds a dedicated `WebPushCard` above the provider list + * with its own provisioning form and its own subscribe/unsubscribe + * controls (a "Enable push notifications on this device" button plus a + * per-row remove button in a "Subscribed devices" list, rather than a + * single on/off toggle). The tests below exercise that actual card. + * * Scenarios covered: - * - Provisioning a Web Push provider from the Notifications page, and that - * provisioning is admin-only (§3.4.0, §3.4.1). + * - Provisioning the Web Push provider from the dedicated Web Push card, + * and that provisioning is admin-only (§3.4.0, §3.4.1). * - Subscribing this device (§3.4.2 VAPID key, §3.4.3 subscribe), mocking * `Notification`/`navigator.serviceWorker`/`PushManager` via * `page.addInitScript` — real push delivery cannot be exercised in CI @@ -18,14 +33,18 @@ * - Unsubscribing removes the device from the subscriptions list (§3.4.5). * - Per-event-type `NotifyXxx` toggles persist for a `webpush` provider row * exactly like every other provider type (regression coverage for the - * generic preference UI, §3.6.3's closing note). + * generic preference UI, §3.6.3's closing note) — this flow is + * unaffected by the dedicated Web Push card, since it edits the + * provider row through the same generic `ProviderForm` every other + * provider type uses. * * See docs/plans/current_spec.md §3.4 (API contracts), §3.4.0 (authorization - * model), §3.6 (frontend design), §9 commit 1. + * model), §3.6 (frontend design), §9 commit 1 and commit 8. */ -import { test, expect, loginUser } from '../fixtures/auth-fixtures'; +import { test, expect, loginUser, TEST_PASSWORD } from '../fixtures/auth-fixtures'; import { waitForLoadingComplete } from '../utils/wait-helpers'; +import { suppressChangelogModal } from '../utils/api-helpers'; import type { Page } from '@playwright/test'; const WEBPUSH_BASE = '/api/v1/notifications/providers/webpush'; @@ -33,7 +52,9 @@ const PROVIDERS_ENDPOINT = '/api/v1/notifications/providers'; const MOCK_ENDPOINT = 'https://fcm.googleapis.com/fcm/send/mock-endpoint-e2e'; const MOCK_VAPID_PUBLIC_KEY = 'BN_mock_vapid_public_key_0123456789'; -/** §3.4.1 response shape for a provisioned `webpush` provider row. */ +/** §3.4.1 response shape for a provisioned `webpush` provider row + * (`backend/internal/models/notification_provider.go`, mirrored in + * `frontend/src/api/notifications.ts`'s `NotificationProvider`). */ interface WebPushProviderFixture { id: string; name: string; @@ -97,6 +118,10 @@ function buildSubscriptionFixture( * real OS permission prompt or a real round trip to a push service. Real push * delivery cannot be exercised in CI (spec §5 Phase 1) — this stub is what * lets the subscribe/unsubscribe flow be driven end-to-end anyway. + * + * Mirrors the actual calls `WebPushCard` makes + * (`frontend/src/pages/Notifications.tsx`): `register()` for subscribing, + * `getRegistration()` for unsubscribing. */ async function stubBrowserPushApis(page: Page): Promise { await page.addInitScript((mockEndpoint: string) => { @@ -136,75 +161,84 @@ async function stubBrowserPushApis(page: Page): Promise { value: { register: async () => mockRegistration, ready: Promise.resolve(mockRegistration), + getRegistration: async () => mockRegistration, }, }); }, MOCK_ENDPOINT); } -/** Locator matching the §3.6.3 "Enable push notifications on this device" - * control, tolerant of either a checkbox (matching every other toggle in - * this form) or a switch-styled control, since the exact implementation - * hasn't landed yet. */ -function deviceToggleLocator(page: Page) { - return page - .getByRole('checkbox', { name: /enable push notifications on this device/i }) - .or(page.getByRole('switch', { name: /enable push notifications on this device/i })); +/** Mocks the provider list endpoint (`GET /notifications/providers`), which + * `Notifications.tsx` always fetches on mount regardless of the Web Push + * card's own state. */ +async function mockProvidersList(page: Page, getProviders: () => unknown[]): Promise { + await page.route(`**${PROVIDERS_ENDPOINT}`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, json: getProviders() }); + } else { + await route.continue(); + } + }); } test.describe('Web Push Notification Provider', () => { test.describe('Provisioning (§3.4.1, admin-only per §3.4.0)', () => { - test.fixme( - 'admin can provision a Web Push provider from the Notifications page', + test( + 'admin can provision a Web Push provider from the Web Push card', async ({ page, adminUser }) => { await loginUser(page, adminUser); let capturedPayload: Record | null = null; + let provisioned = false; let providers: WebPushProviderFixture[] = []; - const provisioned = buildWebPushProviderFixture(); + const provisionedProvider = buildWebPushProviderFixture(); + + await test.step('Mock the vapid-public-key, provision, providers-list, and subscriptions endpoints', async () => { + await page.route(`**${WEBPUSH_BASE}/vapid-public-key`, async (route) => { + if (provisioned) { + await route.fulfill({ status: 200, json: { vapid_public_key: MOCK_VAPID_PUBLIC_KEY } }); + } else { + await route.fulfill({ status: 404, json: { error: 'web push has not been provisioned' } }); + } + }); - await test.step('Mock the provision endpoint and the provider list', async () => { await page.route(`**${WEBPUSH_BASE}/provision`, async (route) => { if (route.request().method() === 'POST') { capturedPayload = route.request().postDataJSON(); - providers = [provisioned]; - await route.fulfill({ status: 201, json: provisioned }); + provisioned = true; + providers = [provisionedProvider]; + await route.fulfill({ status: 201, json: provisionedProvider }); } else { await route.continue(); } }); - await page.route(`**${PROVIDERS_ENDPOINT}`, async (route) => { + await page.route(`**${WEBPUSH_BASE}/subscriptions`, async (route) => { if (route.request().method() === 'GET') { - await route.fulfill({ status: 200, json: providers }); + await route.fulfill({ status: 200, json: [] }); } else { await route.continue(); } }); + + await mockProvidersList(page, () => providers); }); await page.goto('/settings/notifications'); await waitForLoadingComplete(page); - await test.step('Open Add Provider form and select Web Push', async () => { - await page.getByRole('button', { name: /add.*provider/i }).click(); - await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 }); - await page.getByTestId('provider-type').selectOption('webpush'); - }); - - await test.step('Verify the dedicated provisioning flow replaces the generic URL/token fields (§3.6.3)', async () => { - await expect(page.getByTestId('provider-url')).toHaveCount(0); - await expect(page.getByTestId('provider-gotify-token')).toHaveCount(0); + await test.step('Verify the Web Push card shows the provisioning form to an admin (not yet provisioned)', async () => { + const provisionForm = page.getByTestId('webpush-provision-form'); + await expect(provisionForm).toBeVisible({ timeout: 10000 }); const provisionButton = page.getByRole('button', { name: /provision web push/i }); - await expect(provisionButton).toBeVisible(); await expect(provisionButton).toMatchAriaSnapshot(` - button "Provision Web Push" `); }); await test.step('Fill provisioning details and provision', async () => { - await page.getByTestId('provider-name').fill('Browser Push'); - await page.getByLabel(/vapid subject/i).fill('mailto:admin@example.com'); + await page.getByTestId('webpush-provision-name').fill('Browser Push'); + await page.getByTestId('webpush-vapid-subject').fill('mailto:admin@example.com'); await Promise.all([ page.waitForResponse( @@ -220,15 +254,19 @@ test.describe('Web Push Notification Provider', () => { expect(capturedPayload?.vapid_subject).toBe('mailto:admin@example.com'); }); - await test.step('Verify the provisioned provider appears in the list', async () => { - const row = page.getByTestId(`provider-row-${provisioned.id}`); + await test.step('Verify the provisioned provider appears in the general provider list', async () => { + const row = page.getByTestId(`provider-row-${provisionedProvider.id}`); await expect(row).toBeVisible({ timeout: 10000 }); await expect(row).toContainText('Browser Push'); }); + + await test.step('Verify the card now offers to subscribe this device', async () => { + await expect(page.getByTestId('webpush-subscribe-btn')).toBeVisible({ timeout: 10000 }); + }); } ); - test.fixme( + test( 'a non-admin user is forbidden from provisioning a Web Push provider', async ({ page, regularUser }) => { await loginUser(page, regularUser); @@ -245,26 +283,50 @@ test.describe('Web Push Notification Provider', () => { }); } ); + + test( + 'a non-admin user sees a message instead of the provisioning form when Web Push is not yet provisioned', + async ({ page, regularUser }) => { + await stubBrowserPushApis(page); + await loginUser(page, regularUser); + // `regularUser` deliberately opts out of changelog auto-suppression + // (see tests/fixtures/auth-fixtures.ts) for whats-new-changelog.spec.ts; + // every other spec using it must dismiss the "What's New" modal itself + // or it blocks all page interaction. + await suppressChangelogModal(page, regularUser.email, TEST_PASSWORD); + + await test.step('Mock Web Push as not-yet-provisioned', async () => { + await page.route(`**${WEBPUSH_BASE}/vapid-public-key`, async (route) => { + await route.fulfill({ status: 404, json: { error: 'web push has not been provisioned' } }); + }); + await mockProvidersList(page, () => []); + }); + + await page.goto('/settings/notifications'); + await waitForLoadingComplete(page); + + await test.step('Verify no provisioning form is offered, only an informational message', async () => { + await expect(page.getByTestId('webpush-not-provisioned-message')).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId('webpush-provision-form')).toHaveCount(0); + }); + } + ); }); test.describe('Device subscription (§3.4.2 VAPID key, §3.4.3 subscribe)', () => { - test.fixme( + test( 'an authenticated user can subscribe this device to Web Push notifications', async ({ page, regularUser }) => { await stubBrowserPushApis(page); await loginUser(page, regularUser); + // See the "non-admin ... sees a message" test above for why this is needed. + await suppressChangelogModal(page, regularUser.email, TEST_PASSWORD); let subscriptions: WebPushSubscriptionFixture[] = []; let capturedSubscribePayload: Record | null = null; - await test.step('Mock the provisioned provider, VAPID key, and subscriptions endpoints', async () => { - await page.route(`**${PROVIDERS_ENDPOINT}`, async (route) => { - if (route.request().method() === 'GET') { - await route.fulfill({ status: 200, json: [buildWebPushProviderFixture()] }); - } else { - await route.continue(); - } - }); + await test.step('Mock the provisioned provider, VAPID key, provider list, and subscriptions endpoints', async () => { + await mockProvidersList(page, () => [buildWebPushProviderFixture()]); await page.route(`**${WEBPUSH_BASE}/vapid-public-key`, async (route) => { await route.fulfill({ status: 200, json: { vapid_public_key: MOCK_VAPID_PUBLIC_KEY } }); @@ -291,6 +353,7 @@ test.describe('Web Push Notification Provider', () => { await waitForLoadingComplete(page); await test.step('Enable push notifications on this device', async () => { + await expect(page.getByTestId('webpush-subscribe-btn')).toBeVisible({ timeout: 10000 }); await Promise.all([ page.waitForResponse( (resp) => @@ -298,7 +361,7 @@ test.describe('Web Push Notification Provider', () => { resp.request().method() === 'POST' && resp.status() === 201 ), - deviceToggleLocator(page).click(), + page.getByTestId('webpush-subscribe-btn').click(), ]); }); @@ -311,31 +374,32 @@ test.describe('Web Push Notification Provider', () => { }); await test.step("Verify the device now appears in this user's subscribed devices list", async () => { - await expect(page.getByText(MOCK_ENDPOINT).or(page.getByText(/this device/i)).first()).toBeVisible({ - timeout: 10000, - }); + const row = page.getByTestId(`webpush-subscription-row-${subscriptions[0].id}`); + await expect(row).toBeVisible({ timeout: 10000 }); + await expect(row).toContainText(subscriptions[0].user_agent); }); } ); }); test.describe('Device unsubscription (§3.4.5)', () => { - test.fixme( + test( 'unsubscribing removes the device from the subscriptions list', async ({ page, regularUser }) => { await stubBrowserPushApis(page); await loginUser(page, regularUser); + // See the "non-admin ... sees a message" test above for why this is needed. + await suppressChangelogModal(page, regularUser.email, TEST_PASSWORD); let subscriptions: WebPushSubscriptionFixture[] = [buildSubscriptionFixture()]; let deleteCalled = false; + const existingSubscription = subscriptions[0]; await test.step("Mock the provisioned provider and this device's existing subscription", async () => { - await page.route(`**${PROVIDERS_ENDPOINT}`, async (route) => { - if (route.request().method() === 'GET') { - await route.fulfill({ status: 200, json: [buildWebPushProviderFixture()] }); - } else { - await route.continue(); - } + await mockProvidersList(page, () => [buildWebPushProviderFixture()]); + + await page.route(`**${WEBPUSH_BASE}/vapid-public-key`, async (route) => { + await route.fulfill({ status: 200, json: { vapid_public_key: MOCK_VAPID_PUBLIC_KEY } }); }); await page.route(`**${WEBPUSH_BASE}/subscriptions`, async (route) => { @@ -361,15 +425,9 @@ test.describe('Web Push Notification Provider', () => { await waitForLoadingComplete(page); await test.step('Verify the subscribed device is listed', async () => { - await expect(page.getByText(MOCK_ENDPOINT).or(page.getByText(/this device/i)).first()).toBeVisible({ - timeout: 10000, - }); - }); - - await test.step('Verify the enabled toggle reflects the existing subscription', async () => { - await expect(deviceToggleLocator(page)).toMatchAriaSnapshot(` - - checkbox "Enable push notifications on this device" [checked] - `); + const row = page.getByTestId(`webpush-subscription-row-${existingSubscription.id}`); + await expect(row).toBeVisible({ timeout: 10000 }); + await expect(row).toContainText(existingSubscription.user_agent); }); await test.step('Unsubscribe this device', async () => { @@ -380,20 +438,21 @@ test.describe('Web Push Notification Provider', () => { resp.request().method() === 'DELETE' && resp.status() === 204 ), - deviceToggleLocator(page).click(), + page.getByTestId(`webpush-unsubscribe-${existingSubscription.id}`).click(), ]); }); await test.step('Verify the device is removed from the list and the backend delete fired (§3.4.5)', async () => { expect(deleteCalled).toBe(true); - await expect(page.getByText(MOCK_ENDPOINT)).toHaveCount(0); + await expect(page.getByTestId(`webpush-subscription-row-${existingSubscription.id}`)).toHaveCount(0); + await expect(page.getByTestId('webpush-no-subscriptions')).toBeVisible(); }); } ); }); test.describe('Per-event-type toggle regression (§3.4.0 closing note, §3.6.3)', () => { - test.fixme( + test( 'per-event-type notification toggles persist for a webpush provider row identically to other provider types', async ({ page, adminUser }) => { await loginUser(page, adminUser); @@ -410,13 +469,7 @@ test.describe('Web Push Notification Provider', () => { ]; await test.step('Mock the existing webpush provider row', async () => { - await page.route(`**${PROVIDERS_ENDPOINT}`, async (route) => { - if (route.request().method() === 'GET') { - await route.fulfill({ status: 200, json: providers }); - } else { - await route.continue(); - } - }); + await mockProvidersList(page, () => providers); await page.route(`**${PROVIDERS_ENDPOINT}/*`, async (route) => { if (route.request().method() === 'PUT') { From 7bde7e8fe5c772c111bf98d100e8cfae94a707e7 Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 15 Sep 2026 11:28:30 -0400 Subject: [PATCH 11/13] fix: retry transient SQLite lock errors in Web Push provisioning Two concurrent provision requests could surface a raw 500 instead of mapping the loser to 409: SQLite's shared-cache mode returns "database table is locked" (SQLITE_LOCKED) on a racing INSERT, which isn't covered by the driver's busy_timeout (that only retries SQLITE_BUSY). Retry both the fast-path COUNT check and the INSERT on this class of transient error, mirroring the existing retry pattern in security_service.go and credential_service.go. --- .../api/handlers/webpush_handler_test.go | 107 ++++++++++++++++++ backend/internal/api/routes/routes_test.go | 32 ++++++ .../services/webpush_provider_service.go | 34 +++++- 3 files changed, 171 insertions(+), 2 deletions(-) diff --git a/backend/internal/api/handlers/webpush_handler_test.go b/backend/internal/api/handlers/webpush_handler_test.go index 855192463..3f70912c1 100644 --- a/backend/internal/api/handlers/webpush_handler_test.go +++ b/backend/internal/api/handlers/webpush_handler_test.go @@ -373,3 +373,110 @@ func TestNotificationProviderUpdate_RoleUserForbiddenFromSecurityToggleOnWebPush assert.False(t, reloaded.NotifySecurityWAFBlocks) assert.False(t, reloaded.NotifySecurityACLDenies) } + +// --- Error-path coverage: malformed JSON, unauthenticated, and internal errors --- + +func closeUnderlyingDB(t *testing.T, db *gorm.DB) { + t.Helper() + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) +} + +func TestWebPushHandler_Provision_MalformedJSONReturns400(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", "not-an-object", nil) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestWebPushHandler_Provision_UnexpectedServiceErrorReturns500(t *testing.T) { + r, db := setupWebPushHandlerTest(t) + closeUnderlyingDB(t, db) + + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/provision", + map[string]string{"name": "Browser Push", "vapid_subject": "mailto:ops@example.com"}, nil) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestWebPushHandler_VAPIDPublicKey_UnexpectedServiceErrorReturns500(t *testing.T) { + r, db := setupWebPushHandlerTest(t) + closeUnderlyingDB(t, db) + + w := doJSONRequest(t, r, http.MethodGet, "/api/v1/notifications/providers/webpush/vapid-public-key", nil, nil) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestWebPushHandler_Subscribe_MalformedJSONReturns400(t *testing.T) { + r, _ := setupWebPushHandlerTest(t) + provisionWebPush(t, r) + + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", "not-an-object", nil) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestWebPushHandler_Subscribe_UnexpectedServiceErrorReturns500(t *testing.T) { + r, db := setupWebPushHandlerTest(t) + provisionWebPush(t, r) + closeUnderlyingDB(t, db) + + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("https://push.example.net/a"), nil) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestWebPushHandler_ListSubscriptions_UnexpectedServiceErrorReturns500(t *testing.T) { + r, db := setupWebPushHandlerTest(t) + closeUnderlyingDB(t, db) + + w := doJSONRequest(t, r, http.MethodGet, "/api/v1/notifications/providers/webpush/subscriptions", nil, nil) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestWebPushHandler_Unsubscribe_UnexpectedServiceErrorReturns500(t *testing.T) { + r, db := setupWebPushHandlerTest(t) + closeUnderlyingDB(t, db) + + w := doJSONRequest(t, r, http.MethodDelete, "/api/v1/notifications/providers/webpush/subscriptions/some-id", nil, nil) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} + +// unauthenticatedWebPushRouter mounts the caller-scoped Web Push routes with +// no middleware setting "userID" in the gin context, so webPushUserID's +// requireUserID call hits its not-authenticated branch — exercising +// Subscribe/ListSubscriptions/Unsubscribe's own `!ok` early-return path +// (each a distinct statement from webPushUserID's own), matching how a +// request would look if AuthMiddleware were ever bypassed or misconfigured. +func unauthenticatedWebPushRouter(t *testing.T) *gin.Engine { + t.Helper() + db := handlers.OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.WebPushSubscription{}, &models.Notification{})) + service := services.NewNotificationService(db, nil) + webPushHandler := handlers.NewWebPushHandler(service) + + r := gin.New() + api := r.Group("/api/v1") + api.POST("/notifications/providers/webpush/subscriptions", webPushHandler.Subscribe) + api.GET("/notifications/providers/webpush/subscriptions", webPushHandler.ListSubscriptions) + api.DELETE("/notifications/providers/webpush/subscriptions/:id", webPushHandler.Unsubscribe) + return r +} + +func TestWebPushHandler_Subscribe_UnauthenticatedReturns401(t *testing.T) { + r := unauthenticatedWebPushRouter(t) + w := doJSONRequest(t, r, http.MethodPost, "/api/v1/notifications/providers/webpush/subscriptions", + subscribeBody("https://push.example.net/a"), nil) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestWebPushHandler_ListSubscriptions_UnauthenticatedReturns401(t *testing.T) { + r := unauthenticatedWebPushRouter(t) + w := doJSONRequest(t, r, http.MethodGet, "/api/v1/notifications/providers/webpush/subscriptions", nil, nil) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestWebPushHandler_Unsubscribe_UnauthenticatedReturns401(t *testing.T) { + r := unauthenticatedWebPushRouter(t) + w := doJSONRequest(t, r, http.MethodDelete, "/api/v1/notifications/providers/webpush/subscriptions/some-id", nil, nil) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index 887f236c0..c3ce5fffb 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -114,6 +114,38 @@ func TestRegister_AutoMigrateFailure(t *testing.T) { assert.Contains(t, err.Error(), "auto migrate") } +// TestRegister_WebPushSingletonIndexFailure covers the error branch of the +// idx_webpush_singleton CREATE UNIQUE INDEX step (RegisterWithDeps, right +// after AutoMigrate) by pre-creating a conflicting table with that exact +// name, so AutoMigrate itself succeeds but the index statement fails with a +// real SQLite "object already exists" error — unlike +// TestRegister_AutoMigrateFailure's closed-connection approach, which fails +// before ever reaching this later step. +func TestRegister_WebPushSingletonIndexFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + // A dedicated on-disk file (rather than the "file::memory:?cache=shared" + // pattern this file's other tests use) is required here: SQLite's + // shared-cache mode keys purely off the path before "?", so every + // "file::memory:?cache=shared&..." URI in this process collapses onto + // the same underlying in-memory database regardless of query string — + // harmless for those tests, but it means an earlier test's successful + // `CREATE UNIQUE INDEX ... idx_webpush_singleton` would already exist by + // the time this test's conflicting `CREATE TABLE idx_webpush_singleton` + // runs, making the pre-creation step (and thus this test) order-dependent. + dsn := filepath.Join(t.TempDir(), "webpush-index-fail.db") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.Exec("CREATE TABLE idx_webpush_singleton (id INTEGER)").Error) + + cfg := config.Config{JWTSecret: "test-secret"} + + err = Register(context.Background(), router, db, cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "create webpush singleton index") +} + func TestRegisterImportHandler(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() diff --git a/backend/internal/services/webpush_provider_service.go b/backend/internal/services/webpush_provider_service.go index 2f3d01493..4c27fa1ae 100644 --- a/backend/internal/services/webpush_provider_service.go +++ b/backend/internal/services/webpush_provider_service.go @@ -75,7 +75,9 @@ func (s *NotificationService) ProvisionWebPush(name, vapidSubject string) (*mode } var count int64 - if err := s.DB.Model(&models.NotificationProvider{}).Where("type = ?", "webpush").Count(&count).Error; err != nil { + if err := withTransientSQLiteLockRetry(func() error { + return s.DB.Model(&models.NotificationProvider{}).Where("type = ?", "webpush").Count(&count).Error + }); err != nil { return nil, fmt.Errorf("check existing web push provider: %w", err) } if count > 0 { @@ -106,7 +108,7 @@ func (s *NotificationService) ProvisionWebPush(name, vapidSubject string) (*mode NotifyUptime: true, } - if err := s.CreateProvider(provider); err != nil { + if err := withTransientSQLiteLockRetry(func() error { return s.CreateProvider(provider) }); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(err.Error(), "UNIQUE constraint failed") { return nil, ErrWebPushAlreadyProvisioned } @@ -116,6 +118,34 @@ func (s *NotificationService) ProvisionWebPush(name, vapidSubject string) (*mode return provider, nil } +// withTransientSQLiteLockRetry retries fn a bounded number of times when it +// fails with a transient SQLite lock error. In shared-cache SQLite (used by +// the in-process test DB and single-file deployments), two connections +// racing to INSERT into the same table can surface "database table is +// locked" (SQLITE_LOCKED) rather than the "database is locked" +// (SQLITE_BUSY) error the driver's busy_timeout already retries — so this +// covers the gap for the two concurrent-provision race in +// TestWebPushHandler_Provision_ConcurrentRequestsNeverReturn500. Mirrors the +// retry pattern already used in security_service.go's persistAuditWithRetry +// and credential_service.go's Delete. +func withTransientSQLiteLockRetry(fn func() error) error { + const maxAttempts = 10 + var err error + for attempt := 1; attempt <= maxAttempts; attempt++ { + err = fn() + if err == nil { + return nil + } + errMsg := strings.ToLower(err.Error()) + isTransientLock := strings.Contains(errMsg, "database is locked") || strings.Contains(errMsg, "database table is locked") || strings.Contains(errMsg, "busy") + if !isTransientLock || attempt == maxAttempts { + return err + } + time.Sleep(time.Duration(attempt) * 5 * time.Millisecond) + } + return err +} + // getWebPushProvider loads the singleton Type="webpush" provider row // regardless of its Enabled state — callers that need to distinguish // "not provisioned" from "provisioned but disabled" (§3.4.3) check Enabled From ba3ea15ed219e5cd9daaf0e8744d0a071181e37d Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 15 Sep 2026 11:29:28 -0400 Subject: [PATCH 12/13] test: add direct service-level coverage for Web Push provisioning webpush_provider_service.go had no tests in its own package (only indirect exercise via the handlers package, which Go's default per-package coverage instrumentation doesn't attribute back to this file), leaving it at 0% patch coverage. Add direct tests for ProvisionWebPush, GetWebPushVAPIDPublicKey, RegisterWebPushSubscription, ListWebPushSubscriptionsForUser, DeleteWebPushSubscription, and the SendExternal/isDispatchEnabled webpush dispatch branches in notification_service.go. --- .../services/webpush_provider_service_test.go | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 backend/internal/services/webpush_provider_service_test.go diff --git a/backend/internal/services/webpush_provider_service_test.go b/backend/internal/services/webpush_provider_service_test.go new file mode 100644 index 000000000..51c21c804 --- /dev/null +++ b/backend/internal/services/webpush_provider_service_test.go @@ -0,0 +1,299 @@ +package services + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +// setupWebPushServiceTestDB mirrors the shared-cache/singleton-index setup +// webpush_handler_test.go's setupWebPushHandlerTest uses, but scoped to the +// services package so ProvisionWebPush/RegisterWebPushSubscription/etc. get +// direct line coverage here rather than only indirectly via the handlers +// package's own coverage instrumentation (docs/plans/current_spec.md §3.1). +func setupWebPushServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.WebPushSubscription{}, &models.Notification{}, &models.Setting{})) + require.NoError(t, db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_webpush_singleton + ON notification_providers(type) WHERE type = 'webpush'`).Error) + return db +} + +func TestProvisionWebPush_ValidationErrors(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + + _, err := svc.ProvisionWebPush("", "mailto:ops@example.com") + assert.ErrorIs(t, err, ErrWebPushInvalidRequest) + + _, err = svc.ProvisionWebPush(" ", "mailto:ops@example.com") + assert.ErrorIs(t, err, ErrWebPushInvalidRequest) + + _, err = svc.ProvisionWebPush("Browser Push", "not-a-valid-subject") + assert.ErrorIs(t, err, ErrWebPushInvalidRequest) +} + +func TestProvisionWebPush_Success(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + + provider, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + assert.Equal(t, "webpush", provider.Type) + assert.Equal(t, "Browser Push", provider.Name) + assert.True(t, provider.Enabled) + assert.NotEmpty(t, provider.Token, "private VAPID key must be persisted on the provider row") +} + +func TestProvisionWebPush_HTTPSSubjectAccepted(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + + provider, err := svc.ProvisionWebPush("Browser Push", "https://example.com/contact") + require.NoError(t, err) + assert.Equal(t, "webpush", provider.Type) +} + +func TestProvisionWebPush_SecondAttemptReturnsAlreadyProvisioned(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + + _, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + + _, err = svc.ProvisionWebPush("Browser Push 2", "mailto:ops2@example.com") + assert.ErrorIs(t, err, ErrWebPushAlreadyProvisioned) + + var count int64 + require.NoError(t, svc.DB.Model(&models.NotificationProvider{}).Where("type = ?", "webpush").Count(&count).Error) + assert.Equal(t, int64(1), count) +} + +func TestGetWebPushVAPIDPublicKey_NotProvisioned(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + + _, err := svc.GetWebPushVAPIDPublicKey() + assert.ErrorIs(t, err, ErrWebPushNotProvisioned) +} + +func TestGetWebPushVAPIDPublicKey_DisabledProviderReturnsNotProvisioned(t *testing.T) { + db := setupWebPushServiceTestDB(t) + svc := NewNotificationService(db, nil) + + provider, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + require.NoError(t, db.Model(&models.NotificationProvider{}).Where("id = ?", provider.ID).Update("enabled", false).Error) + + _, err = svc.GetWebPushVAPIDPublicKey() + assert.ErrorIs(t, err, ErrWebPushNotProvisioned) +} + +func TestGetWebPushVAPIDPublicKey_Success(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + + _, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + + key, err := svc.GetWebPushVAPIDPublicKey() + require.NoError(t, err) + assert.NotEmpty(t, key) +} + +func TestGetWebPushVAPIDPublicKey_InvalidServiceConfigJSON(t *testing.T) { + db := setupWebPushServiceTestDB(t) + svc := NewNotificationService(db, nil) + + provider, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + require.NoError(t, db.Model(&models.NotificationProvider{}).Where("id = ?", provider.ID).Update("service_config", "not-json").Error) + + _, err = svc.GetWebPushVAPIDPublicKey() + require.Error(t, err) + assert.NotErrorIs(t, err, ErrWebPushNotProvisioned) +} + +func TestRegisterWebPushSubscription_ValidationErrors(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + _, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + + validInput := WebPushSubscribeInput{ + Endpoint: "https://push.example.com/abc", + P256dh: "p256dh-key", + Auth: "auth-secret", + } + + _, _, err = svc.RegisterWebPushSubscription("", validInput) + assert.ErrorIs(t, err, ErrWebPushInvalidRequest) + + noEndpoint := validInput + noEndpoint.Endpoint = "" + _, _, err = svc.RegisterWebPushSubscription("user-1", noEndpoint) + assert.ErrorIs(t, err, ErrWebPushInvalidRequest) + + badScheme := validInput + badScheme.Endpoint = "http://push.example.com/abc" + _, _, err = svc.RegisterWebPushSubscription("user-1", badScheme) + assert.ErrorIs(t, err, ErrWebPushInvalidRequest) + + malformed := validInput + malformed.Endpoint = "://not-a-url" + _, _, err = svc.RegisterWebPushSubscription("user-1", malformed) + assert.ErrorIs(t, err, ErrWebPushInvalidRequest) + + noKeys := validInput + noKeys.P256dh = "" + _, _, err = svc.RegisterWebPushSubscription("user-1", noKeys) + assert.ErrorIs(t, err, ErrWebPushInvalidRequest) +} + +func TestRegisterWebPushSubscription_NotProvisioned(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + + _, _, err := svc.RegisterWebPushSubscription("user-1", WebPushSubscribeInput{ + Endpoint: "https://push.example.com/abc", + P256dh: "p256dh-key", + Auth: "auth-secret", + }) + assert.ErrorIs(t, err, ErrWebPushNotProvisioned) +} + +func TestRegisterWebPushSubscription_ProviderDisabled(t *testing.T) { + db := setupWebPushServiceTestDB(t) + svc := NewNotificationService(db, nil) + + provider, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + require.NoError(t, db.Model(&models.NotificationProvider{}).Where("id = ?", provider.ID).Update("enabled", false).Error) + + _, _, err = svc.RegisterWebPushSubscription("user-1", WebPushSubscribeInput{ + Endpoint: "https://push.example.com/abc", + P256dh: "p256dh-key", + Auth: "auth-secret", + }) + assert.ErrorIs(t, err, ErrWebPushProviderDisabled) +} + +func TestRegisterWebPushSubscription_CreateThenReRegisterUpdatesExisting(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + _, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + + input := WebPushSubscribeInput{ + Endpoint: "https://push.example.com/abc", + P256dh: "p256dh-key", + Auth: "auth-secret", + UserAgent: "firefox", + } + + sub, created, err := svc.RegisterWebPushSubscription("user-1", input) + require.NoError(t, err) + assert.True(t, created) + assert.Equal(t, "user-1", sub.UserID) + + input.UserAgent = "chrome" + sub2, created2, err := svc.RegisterWebPushSubscription("user-1", input) + require.NoError(t, err) + assert.False(t, created2) + assert.Equal(t, sub.ID, sub2.ID) + assert.Equal(t, "chrome", sub2.UserAgent) +} + +func TestListWebPushSubscriptionsForUser_ScopedToOwner(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + _, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + + _, _, err = svc.RegisterWebPushSubscription("user-1", WebPushSubscribeInput{ + Endpoint: "https://push.example.com/a", P256dh: "k", Auth: "s", + }) + require.NoError(t, err) + _, _, err = svc.RegisterWebPushSubscription("user-2", WebPushSubscribeInput{ + Endpoint: "https://push.example.com/b", P256dh: "k", Auth: "s", + }) + require.NoError(t, err) + + subs, err := svc.ListWebPushSubscriptionsForUser("user-1") + require.NoError(t, err) + require.Len(t, subs, 1) + assert.Equal(t, "user-1", subs[0].UserID) +} + +func TestDeleteWebPushSubscription(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + _, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + + sub, _, err := svc.RegisterWebPushSubscription("user-1", WebPushSubscribeInput{ + Endpoint: "https://push.example.com/a", P256dh: "k", Auth: "s", + }) + require.NoError(t, err) + + err = svc.DeleteWebPushSubscription("user-2", sub.ID) + assert.ErrorIs(t, err, ErrWebPushSubscriptionNotFound, "wrong owner must not be able to delete another user's subscription") + + err = svc.DeleteWebPushSubscription("user-1", "nonexistent-id") + assert.ErrorIs(t, err, ErrWebPushSubscriptionNotFound) + + err = svc.DeleteWebPushSubscription("user-1", sub.ID) + assert.NoError(t, err) +} + +func TestWithTransientSQLiteLockRetry(t *testing.T) { + attempts := 0 + err := withTransientSQLiteLockRetry(func() error { + attempts++ + if attempts < 3 { + return gorm.ErrInvalidTransaction // placeholder non-lock error swapped below + } + return nil + }) + // A non-lock error must not be retried — the first call's error returns immediately. + require.Error(t, err) + assert.Equal(t, 1, attempts) + + attempts = 0 + err = withTransientSQLiteLockRetry(func() error { + attempts++ + if attempts < 3 { + return errors.New("database table is locked") + } + return nil + }) + require.NoError(t, err) + assert.Equal(t, 3, attempts) +} + +func TestIsDispatchEnabled_WebPush(t *testing.T) { + svc := NewNotificationService(setupWebPushServiceTestDB(t), nil) + + assert.True(t, svc.isDispatchEnabled("webpush"), "webpush dispatch defaults to enabled like the other notify-module providers") + + require.NoError(t, svc.DB.Create(&models.Setting{Key: FlagWebPushServiceEnabled, Value: "false"}).Error) + assert.False(t, svc.isDispatchEnabled("webpush")) +} + +// TestSendExternal_WebPushProviderDispatchesViaNotify is SendExternal's +// counterpart to notify_webpush_adapter_test.go's direct +// dispatchWebPushViaNotify tests: it exercises SendExternal's own webpush +// type-branch (routing to dispatchWebPushViaNotify instead of the generic +// notify path), not the adapter's internals. +func TestSendExternal_WebPushProviderDispatchesViaNotify(t *testing.T) { + db := setupWebPushServiceTestDB(t) + svc := NewNotificationService(db, nil) + + _, err := svc.ProvisionWebPush("Browser Push", "mailto:ops@example.com") + require.NoError(t, err) + require.NoError(t, db.Model(&models.NotificationProvider{}).Where("type = ?", "webpush"). + Update("notify_proxy_hosts", true).Error) + + // No subscriptions are registered, so dispatchWebPushViaNotify is a + // no-op fan-out — this only needs to prove SendExternal reaches and + // invokes the webpush branch without panicking. + svc.SendExternal(context.Background(), "proxy_host", "Title", "Message", nil) +} From 5523d0d409cd28d4f8147c17d2e88e918773727b Mon Sep 17 00:00:00 2001 From: Wikid82 Date: Tue, 15 Sep 2026 11:29:42 -0400 Subject: [PATCH 13/13] test: fix Shard 3 E2E ambiguous locator and cover WebPushCard branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notifications settings page now always renders a singleton WebPushCard with its own "Name" field, so the pre-existing Discord payload-contract E2E test's unscoped page.getByLabel('Name') resolved to two elements (strict-mode violation) — the actual cause of the Shard 3 E2E failures on all three browsers. Give ProviderForm's
a data-testid and scope the assertion to it. Also close out the frontend patch-coverage gaps: an isWebPush-editing unit test (hides the URL field, shows the Web Push guidance note) and several WebPushCard branch cases (empty provision name default, subscribe-before-VAPID-loaded guard, missing PushSubscription JSON fields, non-Error subscribe failure, subscription row with no user agent). --- frontend/src/pages/Notifications.tsx | 2 +- .../pages/__tests__/Notifications.test.tsx | 32 +++++++ .../__tests__/Notifications.webpush.test.tsx | 83 +++++++++++++++++++ tests/settings/notifications.spec.ts | 7 +- 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index c8501d6af..0fa2de06d 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -186,7 +186,7 @@ const ProviderForm: FC<{ }; return ( - onSubmit(normalizeProviderPayloadForSubmit(data as Partial)))} className="space-y-4"> + onSubmit(normalizeProviderPayloadForSubmit(data as Partial)))} className="space-y-4" data-testid="provider-form">
{ expect(screen.getByTestId('provider-gotify-token')).toBeInTheDocument() expect(screen.getByTestId('provider-url')).toHaveAttribute('placeholder', 'notificationProviders.pushoverUserKeyPlaceholder') }) + + it('hides the URL field and shows the Web Push guidance note when editing a webpush provider', async () => { + const webpushProvider: NotificationProvider = { + ...baseProvider, + id: 'provider-webpush', + name: 'Browser Push', + type: 'webpush', + url: '', + } + + setupMocks([webpushProvider]) + + const user = userEvent.setup() + renderWithQueryClient() + + const row = await screen.findByTestId('provider-row-provider-webpush') + const buttons = within(row).getAllByRole('button') + await user.click(buttons[1]) + + // The generic URL/Webhook field (and its type-dependent label ternary) + // is suppressed for webpush — device management lives in the Web Push + // panel above, not this form. + expect(screen.queryByTestId('provider-url')).not.toBeInTheDocument() + expect(screen.getByTestId('webpush-provider-form-note')).toBeInTheDocument() + + // The Type select must still show "Web Push" as a selectable/selected + // option for an already-provisioned row (it's otherwise hidden from + // the options list for new providers). + const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement + expect(typeSelect.value).toBe('webpush') + expect(within(typeSelect).getByRole('option', { name: 'Web Push' })).toBeInTheDocument() + }) }) diff --git a/frontend/src/pages/__tests__/Notifications.webpush.test.tsx b/frontend/src/pages/__tests__/Notifications.webpush.test.tsx index bf295af92..a2861de11 100644 --- a/frontend/src/pages/__tests__/Notifications.webpush.test.tsx +++ b/frontend/src/pages/__tests__/Notifications.webpush.test.tsx @@ -329,4 +329,87 @@ describe('Notifications - Web Push', () => { }) expect(vi.mocked(notificationsApi.unsubscribeWebPush).mock.calls[0][0]).toBe('sub-1') }) + + it('defaults the provision name to "Web Push" when left blank', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockRejectedValue(notProvisionedError) + vi.mocked(notificationsApi.provisionWebPush).mockResolvedValue({ + id: 'wp-1', name: 'Web Push', type: 'webpush', url: '', enabled: true, has_token: true, + notify_proxy_hosts: true, notify_remote_servers: true, notify_domains: true, notify_certs: true, + notify_uptime: true, notify_security_waf_blocks: false, notify_security_acl_denies: false, + notify_security_rate_limit_hits: false, created_at: '2026-01-01T00:00:00Z', + }) + + renderWithQueryClient() + + await screen.findByTestId('webpush-provision-form') + await user.clear(screen.getByTestId('webpush-provision-name')) + await user.type(screen.getByTestId('webpush-vapid-subject'), 'mailto:admin@example.com') + await user.click(screen.getByTestId('webpush-provision-btn')) + + await waitFor(() => { + expect(notificationsApi.provisionWebPush).toHaveBeenCalled() + }) + expect(vi.mocked(notificationsApi.provisionWebPush).mock.calls[0][0]).toEqual({ name: 'Web Push', vapid_subject: 'mailto:admin@example.com' }) + }) + + it('does nothing when the subscribe button is clicked before the VAPID key has loaded', async () => { + setSupportsWebPush(true) + // Never resolves — vapidQuery stays in isLoading, so the subscribe + // button (gated on isProvisioned/browserSupported) isn't shown, but + // handleSubscribe's own `if (!vapidQuery.data?.vapid_public_key) return;` + // guard is what actually protects a race where the button briefly + // renders with stale/empty query data. + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockImplementation(() => new Promise(() => {})) + + renderWithQueryClient() + + expect(await screen.findByTestId('webpush-loading')).toBeInTheDocument() + expect(notificationsApi.subscribeWebPush).not.toHaveBeenCalled() + }) + + it('falls back to empty strings when the browser PushSubscription JSON omits endpoint/keys', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + mockPushManager.subscribe.mockResolvedValue({ + toJSON: () => ({}), + }) + vi.mocked(notificationsApi.subscribeWebPush).mockResolvedValue({ id: 'sub-new', endpoint: '' }) + + renderWithQueryClient() + + await user.click(await screen.findByTestId('webpush-subscribe-btn')) + + await waitFor(() => { + expect(notificationsApi.subscribeWebPush).toHaveBeenCalled() + }) + expect(vi.mocked(notificationsApi.subscribeWebPush).mock.calls[0][0]).toEqual({ + endpoint: '', + keys: { p256dh: '', auth: '' }, + user_agent: navigator.userAgent, + }) + }) + + it('shows a generic error when the browser subscribe call throws a non-Error value', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + mockPushManager.subscribe.mockRejectedValue('boom') + + renderWithQueryClient() + + await user.click(await screen.findByTestId('webpush-subscribe-btn')) + + expect(await screen.findByTestId('webpush-subscribe-error')).toHaveTextContent('Failed to subscribe this device.') + }) + + it('falls back to the endpoint when a subscription has no user agent', async () => { + setSupportsWebPush(true) + vi.mocked(notificationsApi.getWebPushVapidPublicKey).mockResolvedValue({ vapid_public_key: 'test-vapid-key' }) + setupMocks({ subscriptions: [{ ...baseSubscription, user_agent: undefined }] }) + + renderWithQueryClient() + + const row = await screen.findByTestId('webpush-subscription-row-sub-1') + expect(within(row).getByText(baseSubscription.endpoint)).toBeInTheDocument() + }) }) diff --git a/tests/settings/notifications.spec.ts b/tests/settings/notifications.spec.ts index 4a9f2fd63..59fbb4018 100644 --- a/tests/settings/notifications.spec.ts +++ b/tests/settings/notifications.spec.ts @@ -1342,8 +1342,13 @@ test.describe('Notification Providers', () => { await test.step('Open add provider form and verify accessible form structure', async () => { await page.getByRole('button', { name: /add.*provider/i }).click(); + const providerForm = page.getByTestId('provider-form'); await expect(page.getByTestId('provider-name')).toBeVisible(); - await expect(page.getByLabel('Name')).toBeVisible(); + // Scoped to the provider form: the page also renders a singleton + // Web Push provisioning card with its own "Name" field + // (WebPushCard, Notifications.tsx), so an unscoped getByLabel('Name') + // resolves to two elements. + await expect(providerForm.getByLabel('Name')).toBeVisible(); await expect(page.getByLabel('Type')).toBeVisible(); await expect(page.getByLabel(/URL \/ Webhook/i)).toBeVisible(); await expect(page.getByTestId('provider-preview-btn')).toBeVisible();