Merge pull request #850 from Wikid82/feature/beta-release
Feature: Pushover Notification Provider
This commit is contained in:
2
.github/workflows/security-pr.yml
vendored
2
.github/workflows/security-pr.yml
vendored
@@ -385,7 +385,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to GitHub Security
|
||||
if: always() && steps.trivy-sarif-check.outputs.exists == 'true'
|
||||
# github/codeql-action v4
|
||||
uses: github/codeql-action/upload-sarif@7dd76e6bf79d24133aa649887a6ee01d8b063816
|
||||
uses: github/codeql-action/upload-sarif@fd1ca02d0ddf5bf468c79e6ffb6ffb24f0ecba37
|
||||
with:
|
||||
sarif_file: 'trivy-binary-results.sarif'
|
||||
category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
|
||||
@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Pushover Notification Provider**: Send push notifications to your devices via the Pushover app
|
||||
- Supports JSON templates (minimal, detailed, custom)
|
||||
- Application API Token stored securely — never exposed in API responses
|
||||
- User Key stored in the URL field, following the same pattern as Telegram
|
||||
- Feature flag: `feature.notifications.service.pushover.enabled` (on by default)
|
||||
- Emergency priority (2) is intentionally unsupported — deferred to a future release
|
||||
|
||||
- **Slack Notification Provider**: Send alerts to Slack channels via Incoming Webhooks
|
||||
- Supports JSON templates (minimal, detailed, custom) with Slack's native `text` format
|
||||
- Webhook URL stored securely — never exposed in API responses
|
||||
|
||||
@@ -10,7 +10,7 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
github.com/oschwald/geoip2-golang/v2 v2.1.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
@@ -98,5 +98,5 @@ require (
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
modernc.org/sqlite v1.46.2 // indirect
|
||||
)
|
||||
|
||||
@@ -101,8 +101,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
@@ -263,8 +263,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
|
||||
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -1003,14 +1003,14 @@ func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) {
|
||||
existing := models.NotificationProvider{
|
||||
ID: "unsupported-type",
|
||||
Name: "Custom Provider",
|
||||
Type: "pushover",
|
||||
URL: "https://pushover.example.com/test",
|
||||
Type: "sms",
|
||||
URL: "https://sms.example.com/test",
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "Updated Pushover Provider",
|
||||
"url": "https://pushover.example.com/updated",
|
||||
"name": "Updated SMS Provider",
|
||||
"url": "https://sms.example.com/updated",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
|
||||
@@ -367,7 +367,7 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) {
|
||||
requestFunc: func(id string) (*http.Request, gin.Params) {
|
||||
payload := map[string]interface{}{
|
||||
"name": "Test",
|
||||
"type": "pushover",
|
||||
"type": "sms",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
@@ -182,7 +182,7 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
providerType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" {
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
|
||||
return
|
||||
}
|
||||
@@ -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" {
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
|
||||
return
|
||||
}
|
||||
|
||||
if (providerType == "gotify" || providerType == "telegram" || providerType == "slack") && strings.TrimSpace(req.Token) == "" {
|
||||
if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover") && strings.TrimSpace(req.Token) == "" {
|
||||
// Keep existing token if update payload omits token
|
||||
req.Token = existing.Token
|
||||
}
|
||||
@@ -326,6 +326,16 @@ func (h *NotificationProviderHandler) Test(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if providerType == "telegram" && strings.TrimSpace(req.Token) != "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Telegram bot token is accepted only on provider create/update")
|
||||
return
|
||||
}
|
||||
|
||||
if providerType == "pushover" && strings.TrimSpace(req.Token) != "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Pushover API token is accepted only on provider create/update")
|
||||
return
|
||||
}
|
||||
|
||||
// Email providers use global SMTP + recipients from the URL field; they don't require a saved provider ID.
|
||||
if providerType == "email" {
|
||||
provider := models.NotificationProvider{
|
||||
|
||||
@@ -668,3 +668,35 @@ func TestNotificationProviderHandler_List_TelegramNeverExposesBotToken(t *testin
|
||||
_, hasTokenField := raw[0]["token"]
|
||||
assert.False(t, hasTokenField, "raw token field must not appear in JSON response")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_TelegramTokenRejected(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "telegram",
|
||||
"token": "bot123:TOKEN",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_PushoverTokenRejected(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "pushover",
|
||||
"token": "app-token-abc",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ func TestFinalBlocker3_SupportedProviderTypes_UnsupportedTypesIgnored(t *testing
|
||||
db := SetupCompatibilityTestDB(t)
|
||||
|
||||
// Create ONLY unsupported providers
|
||||
unsupportedTypes := []string{"pushover", "generic"}
|
||||
unsupportedTypes := []string{"sms", "generic"}
|
||||
|
||||
for _, providerType := range unsupportedTypes {
|
||||
provider := &models.NotificationProvider{
|
||||
|
||||
@@ -8,5 +8,6 @@ const (
|
||||
FlagWebhookServiceEnabled = "feature.notifications.service.webhook.enabled"
|
||||
FlagTelegramServiceEnabled = "feature.notifications.service.telegram.enabled"
|
||||
FlagSlackServiceEnabled = "feature.notifications.service.slack.enabled"
|
||||
FlagPushoverServiceEnabled = "feature.notifications.service.pushover.enabled"
|
||||
FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled"
|
||||
)
|
||||
|
||||
@@ -25,6 +25,10 @@ func (r *Router) ShouldUseNotify(providerType string, flags map[string]bool) boo
|
||||
return flags[FlagWebhookServiceEnabled]
|
||||
case "telegram":
|
||||
return flags[FlagTelegramServiceEnabled]
|
||||
case "slack":
|
||||
return flags[FlagSlackServiceEnabled]
|
||||
case "pushover":
|
||||
return flags[FlagPushoverServiceEnabled]
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -86,3 +86,39 @@ func TestRouter_ShouldUseNotify_WebhookServiceFlag(t *testing.T) {
|
||||
t.Fatalf("expected notify routing disabled for webhook when FlagWebhookServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_ShouldUseNotify_SlackServiceFlag(t *testing.T) {
|
||||
router := NewRouter()
|
||||
|
||||
flags := map[string]bool{
|
||||
FlagNotifyEngineEnabled: true,
|
||||
FlagSlackServiceEnabled: true,
|
||||
}
|
||||
|
||||
if !router.ShouldUseNotify("slack", flags) {
|
||||
t.Fatalf("expected notify routing enabled for slack when FlagSlackServiceEnabled is true")
|
||||
}
|
||||
|
||||
flags[FlagSlackServiceEnabled] = false
|
||||
if router.ShouldUseNotify("slack", flags) {
|
||||
t.Fatalf("expected notify routing disabled for slack when FlagSlackServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_ShouldUseNotify_PushoverServiceFlag(t *testing.T) {
|
||||
router := NewRouter()
|
||||
|
||||
flags := map[string]bool{
|
||||
FlagNotifyEngineEnabled: true,
|
||||
FlagPushoverServiceEnabled: true,
|
||||
}
|
||||
|
||||
if !router.ShouldUseNotify("pushover", flags) {
|
||||
t.Fatalf("expected notify routing enabled for pushover when FlagPushoverServiceEnabled is true")
|
||||
}
|
||||
|
||||
flags[FlagPushoverServiceEnabled] = false
|
||||
if router.ShouldUseNotify("pushover", flags) {
|
||||
t.Fatalf("expected notify routing disabled for pushover when FlagPushoverServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ func (s *EnhancedSecurityNotificationService) getProviderAggregatedConfig() (*mo
|
||||
"slack": true,
|
||||
"gotify": true,
|
||||
"telegram": true,
|
||||
"pushover": true,
|
||||
}
|
||||
filteredProviders := []models.NotificationProvider{}
|
||||
for _, p := range providers {
|
||||
|
||||
@@ -30,6 +30,7 @@ type NotificationService struct {
|
||||
httpWrapper *notifications.HTTPWrapper
|
||||
mailService MailServiceInterface
|
||||
telegramAPIBaseURL string
|
||||
pushoverAPIBaseURL string
|
||||
validateSlackURL func(string) error
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ func NewNotificationService(db *gorm.DB, mailService MailServiceInterface, opts
|
||||
httpWrapper: notifications.NewNotifyHTTPWrapper(),
|
||||
mailService: mailService,
|
||||
telegramAPIBaseURL: "https://api.telegram.org",
|
||||
pushoverAPIBaseURL: "https://api.pushover.net",
|
||||
validateSlackURL: validateSlackWebhookURL,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
@@ -127,7 +129,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":
|
||||
case "webhook", "discord", "gotify", "slack", "generic", "telegram", "pushover":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -136,7 +138,7 @@ func supportsJSONTemplates(providerType string) bool {
|
||||
|
||||
func isSupportedNotificationProviderType(providerType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(providerType)) {
|
||||
case "discord", "email", "gotify", "webhook", "telegram", "slack":
|
||||
case "discord", "email", "gotify", "webhook", "telegram", "slack", "pushover":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -157,6 +159,8 @@ func (s *NotificationService) isDispatchEnabled(providerType string) bool {
|
||||
return s.getFeatureFlagValue(notifications.FlagTelegramServiceEnabled, true)
|
||||
case "slack":
|
||||
return s.getFeatureFlagValue(notifications.FlagSlackServiceEnabled, true)
|
||||
case "pushover":
|
||||
return s.getFeatureFlagValue(notifications.FlagPushoverServiceEnabled, true)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -507,9 +511,18 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti
|
||||
return fmt.Errorf("telegram payload requires 'text' field")
|
||||
}
|
||||
}
|
||||
case "pushover":
|
||||
if _, hasMessage := jsonPayload["message"]; !hasMessage {
|
||||
return fmt.Errorf("pushover payload requires 'message' field")
|
||||
}
|
||||
if priority, ok := jsonPayload["priority"]; ok {
|
||||
if p, isFloat := priority.(float64); isFloat && p == 2 {
|
||||
return fmt.Errorf("pushover emergency priority (2) requires retry and expire parameters; not yet supported")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" {
|
||||
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" {
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Charon-Notify/1.0",
|
||||
@@ -566,6 +579,41 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti
|
||||
dispatchURL = decryptedWebhookURL
|
||||
}
|
||||
|
||||
if providerType == "pushover" {
|
||||
decryptedToken := p.Token
|
||||
if strings.TrimSpace(decryptedToken) == "" {
|
||||
return fmt.Errorf("pushover API token is not configured")
|
||||
}
|
||||
if strings.TrimSpace(p.URL) == "" {
|
||||
return fmt.Errorf("pushover user key is not configured")
|
||||
}
|
||||
|
||||
pushoverBase := s.pushoverAPIBaseURL
|
||||
if pushoverBase == "" {
|
||||
pushoverBase = "https://api.pushover.net"
|
||||
}
|
||||
dispatchURL = pushoverBase + "/1/messages.json"
|
||||
|
||||
parsedURL, parseErr := neturl.Parse(dispatchURL)
|
||||
expectedHost := "api.pushover.net"
|
||||
if parsedURL != nil && parsedURL.Hostname() != "" && pushoverBase != "https://api.pushover.net" {
|
||||
expectedHost = parsedURL.Hostname()
|
||||
}
|
||||
if parseErr != nil || parsedURL.Hostname() != expectedHost {
|
||||
return fmt.Errorf("pushover dispatch URL validation failed: invalid hostname")
|
||||
}
|
||||
|
||||
jsonPayload["token"] = decryptedToken
|
||||
jsonPayload["user"] = p.URL
|
||||
|
||||
updatedBody, marshalErr := json.Marshal(jsonPayload)
|
||||
if marshalErr != nil {
|
||||
return fmt.Errorf("failed to marshal pushover payload: %w", marshalErr)
|
||||
}
|
||||
body.Reset()
|
||||
body.Write(updatedBody)
|
||||
}
|
||||
|
||||
if _, sendErr := s.httpWrapper.Send(ctx, notifications.HTTPWrapperRequest{
|
||||
URL: dispatchURL,
|
||||
Headers: headers,
|
||||
|
||||
@@ -1829,7 +1829,7 @@ func TestTestProvider_NotifyOnlyRejectsUnsupportedProvider(t *testing.T) {
|
||||
providerType string
|
||||
url string
|
||||
}{
|
||||
{"pushover", "pushover", "pushover://token@user"},
|
||||
{"sms", "sms", "sms://token@user"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -2156,9 +2156,9 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) {
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "Pushover Provider (deprecated)",
|
||||
Type: "pushover",
|
||||
URL: "pushover://token@user",
|
||||
Name: "Legacy SMS Provider (deprecated)",
|
||||
Type: "legacy_sms",
|
||||
URL: "sms://token@user",
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
@@ -2167,6 +2167,13 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) {
|
||||
URL: "https://discord.com/api/webhooks/123/abc/gotify",
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "Pushover Provider",
|
||||
Type: "pushover",
|
||||
Token: "pushover-api-token",
|
||||
URL: "pushover-user-key",
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
for i := range providers {
|
||||
@@ -2187,7 +2194,7 @@ func TestNotificationService_EnsureNotifyOnlyProviderMigration(t *testing.T) {
|
||||
assert.True(t, discord.Enabled, "discord provider should remain enabled")
|
||||
|
||||
// Verify non-Discord providers are marked as deprecated and disabled
|
||||
nonDiscordTypes := []string{"webhook", "telegram", "pushover", "gotify"}
|
||||
nonDiscordTypes := []string{"webhook", "telegram", "legacy_sms", "gotify", "pushover"}
|
||||
for _, providerType := range nonDiscordTypes {
|
||||
var provider models.NotificationProvider
|
||||
require.NoError(t, db.Where("type = ?", providerType).First(&provider).Error)
|
||||
@@ -3612,3 +3619,262 @@ func TestUpdateProvider_Slack_UnchangedTokenSkipsValidation(t *testing.T) {
|
||||
err := svc.UpdateProvider(&update)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// --- Pushover Notification Provider Tests ---
|
||||
|
||||
func TestPushoverDispatch_Success(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
|
||||
var capturedBody []byte
|
||||
var capturedURL string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedURL = r.URL.Path
|
||||
capturedBody, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewNotificationService(db, nil)
|
||||
svc.pushoverAPIBaseURL = server.URL
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "pushover",
|
||||
Token: "app-token-abc",
|
||||
URL: "user-key-xyz",
|
||||
Template: "minimal",
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "Test",
|
||||
"Message": "Hello Pushover",
|
||||
"Time": time.Now().Format(time.RFC3339),
|
||||
"EventType": "test",
|
||||
}
|
||||
|
||||
err := svc.sendJSONPayload(context.Background(), provider, data)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/1/messages.json", capturedURL)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(capturedBody, &payload))
|
||||
assert.Equal(t, "app-token-abc", payload["token"])
|
||||
assert.Equal(t, "user-key-xyz", payload["user"])
|
||||
assert.NotEmpty(t, payload["message"])
|
||||
}
|
||||
|
||||
func TestPushoverDispatch_MissingToken(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "pushover",
|
||||
Token: "",
|
||||
URL: "user-key-xyz",
|
||||
Template: "minimal",
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "Test",
|
||||
"Message": "Hello",
|
||||
"Time": time.Now().Format(time.RFC3339),
|
||||
"EventType": "test",
|
||||
}
|
||||
|
||||
err := svc.sendJSONPayload(context.Background(), provider, data)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "pushover API token is not configured")
|
||||
}
|
||||
|
||||
func TestPushoverDispatch_MissingUserKey(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "pushover",
|
||||
Token: "app-token-abc",
|
||||
URL: "",
|
||||
Template: "minimal",
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "Test",
|
||||
"Message": "Hello",
|
||||
"Time": time.Now().Format(time.RFC3339),
|
||||
"EventType": "test",
|
||||
}
|
||||
|
||||
err := svc.sendJSONPayload(context.Background(), provider, data)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "pushover user key is not configured")
|
||||
}
|
||||
|
||||
func TestPushoverDispatch_MessageFieldRequired(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "pushover",
|
||||
Token: "app-token-abc",
|
||||
URL: "user-key-xyz",
|
||||
Template: "custom",
|
||||
Config: `{"title": {{toJSON .Title}}}`,
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "Test",
|
||||
"Message": "Hello",
|
||||
"Time": time.Now().Format(time.RFC3339),
|
||||
"EventType": "test",
|
||||
}
|
||||
|
||||
err := svc.sendJSONPayload(context.Background(), provider, data)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "pushover payload requires 'message' field")
|
||||
}
|
||||
|
||||
func TestPushoverDispatch_EmergencyPriorityRejected(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "pushover",
|
||||
Token: "app-token-abc",
|
||||
URL: "user-key-xyz",
|
||||
Template: "custom",
|
||||
Config: `{"message": {{toJSON .Message}}, "priority": 2}`,
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "Emergency",
|
||||
"Message": "Critical alert",
|
||||
"Time": time.Now().Format(time.RFC3339),
|
||||
"EventType": "test",
|
||||
}
|
||||
|
||||
err := svc.sendJSONPayload(context.Background(), provider, data)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "pushover emergency priority (2) requires retry and expire parameters")
|
||||
}
|
||||
|
||||
func TestPushoverDispatch_PayloadInjection(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
|
||||
var capturedBody []byte
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedBody, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewNotificationService(db, nil)
|
||||
svc.pushoverAPIBaseURL = server.URL
|
||||
|
||||
// Template tries to set token/user — server-side injection must overwrite them.
|
||||
provider := models.NotificationProvider{
|
||||
Type: "pushover",
|
||||
Token: "real-token",
|
||||
URL: "real-user-key",
|
||||
Template: "custom",
|
||||
Config: `{"message": "hi", "token": "fake-token", "user": "fake-user"}`,
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "Test",
|
||||
"Message": "hi",
|
||||
"Time": time.Now().Format(time.RFC3339),
|
||||
"EventType": "test",
|
||||
}
|
||||
|
||||
err := svc.sendJSONPayload(context.Background(), provider, data)
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(capturedBody, &payload))
|
||||
assert.Equal(t, "real-token", payload["token"])
|
||||
assert.Equal(t, "real-user-key", payload["user"])
|
||||
}
|
||||
|
||||
func TestPushoverDispatch_FeatureFlagDisabled(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
_ = db.AutoMigrate(&models.Setting{})
|
||||
db.Create(&models.Setting{Key: "feature.notifications.service.pushover.enabled", Value: "false"})
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
assert.False(t, svc.isDispatchEnabled("pushover"))
|
||||
}
|
||||
|
||||
func TestPushoverDispatch_SSRFValidation(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
|
||||
var capturedHost string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedHost = r.Host
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewNotificationService(db, nil)
|
||||
svc.pushoverAPIBaseURL = server.URL
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "pushover",
|
||||
Token: "app-token-abc",
|
||||
URL: "user-key-xyz",
|
||||
Template: "minimal",
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "Test",
|
||||
"Message": "SSRF check",
|
||||
"Time": time.Now().Format(time.RFC3339),
|
||||
"EventType": "test",
|
||||
}
|
||||
|
||||
err := svc.sendJSONPayload(context.Background(), provider, data)
|
||||
require.NoError(t, err)
|
||||
// The test server URL is used; production code would enforce api.pushover.net.
|
||||
// Verify dispatch succeeds and path is correct.
|
||||
_ = capturedHost
|
||||
}
|
||||
|
||||
func TestIsDispatchEnabled_PushoverDefaultTrue(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
// No flag in DB — should default to true (enabled)
|
||||
assert.True(t, svc.isDispatchEnabled("pushover"))
|
||||
}
|
||||
|
||||
func TestIsDispatchEnabled_PushoverDisabledByFlag(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
_ = db.AutoMigrate(&models.Setting{})
|
||||
db.Create(&models.Setting{Key: "feature.notifications.service.pushover.enabled", Value: "false"})
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
assert.False(t, svc.isDispatchEnabled("pushover"))
|
||||
}
|
||||
|
||||
func TestPushoverDispatch_DefaultBaseURL(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db, nil)
|
||||
// Reset the test seam to "" so the defensive 'if pushoverBase == ""' path executes,
|
||||
// setting it to the production URL "https://api.pushover.net".
|
||||
svc.pushoverAPIBaseURL = ""
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "pushover",
|
||||
Token: "test-token",
|
||||
URL: "test-user-key",
|
||||
Template: "minimal",
|
||||
}
|
||||
data := map[string]any{
|
||||
"Title": "Test",
|
||||
"Message": "Hello",
|
||||
"Time": time.Now().Format(time.RFC3339),
|
||||
"EventType": "test",
|
||||
}
|
||||
|
||||
// Pre-cancel the context so the HTTP send fails immediately.
|
||||
// The defensive path (assigning the production base URL) still executes before any I/O.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := svc.sendJSONPayload(ctx, provider, data)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ Notifications can be triggered by various events:
|
||||
| **Discord** | ✅ Yes | ✅ Webhooks | ✅ Embeds |
|
||||
| **Slack** | ✅ Yes | ✅ Webhooks | ✅ Native Formatting |
|
||||
| **Gotify** | ✅ Yes | ✅ HTTP API | ✅ Priority + Extras |
|
||||
| **Pushover** | ✅ Yes | ✅ HTTP API | ✅ Priority + Sound |
|
||||
| **Custom Webhook** | ✅ Yes | ✅ HTTP API | ✅ Template-Controlled |
|
||||
| **Email** | ❌ No | ✅ SMTP | ✅ HTML Branded Templates |
|
||||
|
||||
@@ -214,6 +215,51 @@ Slack notifications send messages to a channel using an Incoming Webhook URL.
|
||||
- Use `•` for bullet points
|
||||
- Slack automatically linkifies URLs
|
||||
|
||||
### Pushover
|
||||
|
||||
Pushover delivers push notifications directly to your iOS, Android, or desktop devices.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Create an account at [pushover.net](https://pushover.net) and install the Pushover app on your device
|
||||
2. From your Pushover dashboard, copy your **User Key**
|
||||
3. Create a new **Application/API Token** for Charon
|
||||
4. In Charon, go to **Settings** → **Notifications** and click **"Add Provider"**
|
||||
5. Select **Pushover** as the service type
|
||||
6. Enter your **Application API Token** in the token field
|
||||
7. Enter your **User Key** in the User Key field
|
||||
8. Configure notification triggers and save
|
||||
|
||||
> **Security:** Your Application API Token is stored securely and is never exposed in API responses.
|
||||
|
||||
#### Basic Message
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "{{.Title}}",
|
||||
"message": "{{.Message}}"
|
||||
}
|
||||
```
|
||||
|
||||
#### Message with Priority
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "{{.Title}}",
|
||||
"message": "{{.Message}}",
|
||||
"priority": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Pushover priority levels:**
|
||||
|
||||
- `-2` - Lowest (no sound or vibration)
|
||||
- `-1` - Low (quiet)
|
||||
- `0` - Normal (default)
|
||||
- `1` - High (bypass quiet hours)
|
||||
|
||||
> **Note:** Emergency priority (`2`) is not supported and will be rejected with a clear error.
|
||||
|
||||
## Planned Provider Expansion
|
||||
|
||||
Additional providers (for example Telegram) are planned for later staged
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
309
docs/plans/current_spec.md.bak2
Normal file
309
docs/plans/current_spec.md.bak2
Normal file
@@ -0,0 +1,309 @@
|
||||
# Fix Plan: 6 HIGH CVEs in node:24.14.0-alpine frontend-builder Stage
|
||||
|
||||
**Status:** Active
|
||||
**Created:** 2026-03-16
|
||||
**Branch:** `fix/node-alpine-cve-remediation`
|
||||
**Scope:** `Dockerfile` — `frontend-builder` stage only
|
||||
**Previous Plan:** Backed up to `docs/plans/current_spec.md.bak`
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
The `frontend-builder` stage in the multi-stage `Dockerfile` is pinned to:
|
||||
|
||||
```dockerfile
|
||||
# renovate: datasource=docker depName=node
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine@sha256:7fddd9ddeae8196abf4a3ef2de34e11f7b1a722119f91f28ddf1e99dcafdf114 AS frontend-builder
|
||||
```
|
||||
|
||||
Docker Scout (via Docker Hub) and Grype/Trivy scans report **6 HIGH-severity CVEs** in this image. Although the `frontend-builder` stage is build-time only and does not appear in the final runtime image, these CVEs are still relevant for **supply chain security**: CI scans, SBOM attestations, and SLSA provenance all inspect intermediate build stages. Failing to address them causes CI gates to fail and weakens the supply chain posture.
|
||||
|
||||
---
|
||||
|
||||
## 2. Research Findings
|
||||
|
||||
### 2.1 Current Image
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Tag | `node:24.14.0-alpine` |
|
||||
| Multi-arch index digest (used in FROM) | `sha256:7fddd9ddeae8196abf4a3ef2de34e11f7b1a722119f91f28ddf1e99dcafdf114` |
|
||||
| amd64 platform-specific manifest digest | `sha256:e9445c64ace1a9b5cdc60fc98dd82d1e5142985d902f41c2407e8fffe49d46a3` |
|
||||
| arm64/v8 platform-specific manifest digest | `sha256:0e0d39e04fdf3dc5f450a07922573bac666d28920df2df3f3b1540b0aba7ab98` |
|
||||
| Base Alpine version | Alpine 3.23 |
|
||||
| Compressed size (amd64) | 53.63 MB |
|
||||
| Last pushed on Docker Hub | 2026-02-26 (19 days before research date) |
|
||||
|
||||
### 2.2 Docker Hub Floating Tag Alignment
|
||||
|
||||
`docker manifest inspect node:24-alpine` confirmed on 2026-03-16:
|
||||
|
||||
- amd64: `sha256:e9445c64ace1a9b5cdc60fc98dd82d1e5142985d902f41c2407e8fffe49d46a3`
|
||||
- arm64/v8: `sha256:0e0d39e04fdf3dc5f450a07922573bac666d28920df2df3f3b1540b0aba7ab98`
|
||||
- s390x: `sha256:965b4135b1067dca4b1aff58675c9b9a1f028d57e30c2e0d39bcd9863605ad62`
|
||||
|
||||
The Docker Hub layers page for the amd64 manifest confirms **INDEX DIGEST: `sha256:7fddd9ddeae8196abf4a3ef2de34e11f7b1a722119f91f28ddf1e99dcafdf114`** — exactly matching the digest pinned in the Dockerfile.
|
||||
|
||||
**`node:24-alpine`, `node:24-alpine3.23`, and `node:24.14.0-alpine` all resolve to the identical multi-arch index digest.** There is no newer `node:24.x.y-alpine` image on Docker Hub as of 2026-03-16.
|
||||
|
||||
### 2.3 CVE Summary
|
||||
|
||||
Docker Scout scan of `node:24-alpine` amd64 manifest `sha256:e9445c64ace1...`:
|
||||
|
||||
| CVE ID | CVSS | Severity | Package manager | Package | Version |
|
||||
|---|---|---|---|---|---|
|
||||
| CVE-2026-26996 | 8.7 | **HIGH** | npm | minimatch | 10.1.2 |
|
||||
| CVE-2026-29786 | 8.2 | **HIGH** | npm | tar | 7.5.7 |
|
||||
| CVE-2026-31802 | 8.2 | **HIGH** | npm | tar | 7.5.7 |
|
||||
| CVE-2026-27904 | 7.5 | **HIGH** | npm | minimatch | 10.1.2 |
|
||||
| CVE-2026-27903 | 7.5 | **HIGH** | npm | minimatch | 10.1.2 |
|
||||
| CVE-2026-26960 | 7.1 | **HIGH** | npm | tar | 7.5.7 |
|
||||
| CVE-2025-60876 | 6.5 | MEDIUM | apk | alpine/busybox | 1.37.0-r30 |
|
||||
| CVE-2026-22184 | 4.6 | MEDIUM | apk | alpine/zlib | 1.3.1-r2 |
|
||||
| CVE-2026-27171 | 2.9 | LOW | apk | alpine/zlib | 1.3.1-r2 |
|
||||
|
||||
**Total: 0 Critical, 6 High, 2 Medium, 1 Low**
|
||||
**Docker Scout fixability as of 2026-03-16: 0 Fixable** (no patched versions yet available in Alpine apk repositories or npm registry)
|
||||
|
||||
### 2.4 CVE Location Analysis
|
||||
|
||||
All **6 HIGH** CVEs are in **npm's own internally-bundled packages**, not in the frontend project's `node_modules`. These packages live inside the image at:
|
||||
|
||||
```
|
||||
/usr/local/lib/node_modules/npm/node_modules/minimatch/ ← CVE-2026-26996, CVE-2026-27904, CVE-2026-27903
|
||||
/usr/local/lib/node_modules/npm/node_modules/tar/ ← CVE-2026-29786, CVE-2026-31802, CVE-2026-26960
|
||||
```
|
||||
|
||||
`minimatch` is used by the npm CLI for glob pattern matching. `tar` is used by npm for `.tgz` tarball extraction during `npm install`/`npm ci`. These are NOT declared in `frontend/package.json`; they are shipped inside the npm CLI binary itself.
|
||||
|
||||
The **2 MEDIUM + 1 LOW** CVEs are in Alpine OS packages managed by apk:
|
||||
- `busybox@1.37.0-r30`: CVE-2025-60876
|
||||
- `zlib@1.3.1-r2`: CVE-2026-22184, CVE-2026-27171
|
||||
|
||||
### 2.5 `apk upgrade` Effectiveness
|
||||
|
||||
`apk upgrade --no-cache` operates exclusively on Alpine apk-managed packages. It has no effect on files under `/usr/local/lib/node_modules/`.
|
||||
|
||||
| CVE set | Fixed by `apk upgrade`? |
|
||||
|---|---|
|
||||
| 6 HIGH (npm/minimatch, npm/tar) | **No** — these are npm-managed, not apk-managed |
|
||||
| 2 MEDIUM + 1 LOW (apk/busybox, apk/zlib) | **Yes, once Alpine maintainers publish patches** — currently 0 fixable per Docker Scout, but the `apk upgrade` step will apply patches automatically when they land |
|
||||
|
||||
### 2.6 Renovate Automation
|
||||
|
||||
The Dockerfile already carries the correct Renovate comment on the line immediately before the FROM:
|
||||
|
||||
```dockerfile
|
||||
# renovate: datasource=docker depName=node
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine@sha256:7fddd9... AS frontend-builder
|
||||
```
|
||||
|
||||
When the Node.js project publishes `node:24.15.0-alpine` (or later) to Docker Hub, Renovate will automatically propose a PR updating the version tag (`24.14.0` → next) and the `@sha256:` digest to the new multi-arch index. That Renovate PR is the **definitive fix path** because the new release will ship npm bundling patched `minimatch` and `tar`.
|
||||
|
||||
### 2.7 Risk Assessment
|
||||
|
||||
| Risk factor | Assessment |
|
||||
|---|---|
|
||||
| Appears in final runtime image | **No** — only the compiled `dist/` output is `COPY`-ed to the final stage |
|
||||
| Exploitable at runtime | **No** — `npm`, `minimatch`, and `tar` are not present in the final image |
|
||||
| Exploitable during build | Theoretical (supply chain attack on the build worker) |
|
||||
| CI scan failures | **Yes** — Grype/Trivy flag build stages; this is the main driver for the fix |
|
||||
| SBOM/SLSA impact | **Yes** — SBOM includes build-stage packages; HIGH CVEs degrade attestation quality |
|
||||
|
||||
---
|
||||
|
||||
## 3. Technical Specification
|
||||
|
||||
### 3.1 FROM Line — No Change (No Newer Image Available)
|
||||
|
||||
Since `node:24-alpine` and `node:24.14.0-alpine` resolve to the **same** multi-arch index digest (`sha256:7fddd9...`), there is no newer pinned image to upgrade to. **The FROM line does not change.** Renovate handles future image bumps autonomously.
|
||||
|
||||
### 3.2 Changes to `frontend-builder` Stage
|
||||
|
||||
**Single file changed:** `Dockerfile`
|
||||
|
||||
**Locations:** Two changes in `Dockerfile`.
|
||||
|
||||
**Change A — Top-level ARG (Pinned Toolchain Versions block):**
|
||||
|
||||
Add after the existing `ARG XNET_VERSION` line in the `# ---- Pinned Toolchain Versions ----` section:
|
||||
|
||||
```diff
|
||||
# renovate: datasource=go depName=golang.org/x/net
|
||||
ARG XNET_VERSION=0.51.0
|
||||
+
|
||||
+# renovate: datasource=npm depName=npm
|
||||
+ARG NPM_VERSION=11.11.1
|
||||
```
|
||||
|
||||
**Change B — `frontend-builder` stage (before `RUN npm ci`):**
|
||||
|
||||
```diff
|
||||
# Vite 8: Rolldown native bindings auto-resolved per platform via optionalDependencies
|
||||
|
||||
+# Upgrade npm to replace its bundled minimatch/tar with patched versions
|
||||
+# Addresses: CVE-2026-26996, CVE-2026-27903, CVE-2026-27904 (npm/minimatch)
|
||||
+# CVE-2026-26960, CVE-2026-29786, CVE-2026-31802 (npm/tar)
|
||||
+# Run apk upgrade for Alpine package CVEs (busybox, zlib) once patches land
|
||||
+# hadolint ignore=DL3017
|
||||
+RUN apk upgrade --no-cache && \
|
||||
+ npm install -g npm@${NPM_VERSION} --no-fund --no-audit && \
|
||||
+ npm cache clean --force
|
||||
+
|
||||
RUN npm ci
|
||||
```
|
||||
|
||||
### 3.3 Step-by-Step Rationale
|
||||
|
||||
| Added command | Rationale |
|
||||
|---|---|
|
||||
| `apk upgrade --no-cache` | Applies any Alpine repo patches for busybox (CVE-2025-60876) and zlib (CVE-2026-22184, CVE-2026-27171) without changing the base image pin. Currently 0 fixable per Docker Scout, but will take effect automatically once Alpine maintainers ship packages. |
|
||||
| `npm install -g npm@${NPM_VERSION} --no-fund --no-audit` | Replaces `/usr/local/lib/node_modules/npm/` (and its bundled `minimatch` + `tar`) with the pinned npm release from the npm registry. `NPM_VERSION` is declared as `11.11.1` in the top-level Pinned Toolchain Versions ARG block and tracked by Renovate's npm datasource manager. `--no-fund` and `--no-audit` suppress log noise during build. If a patched npm has been published since the node image was created, this eliminates the 6 HIGH CVEs. |
|
||||
| `npm cache clean --force` | Clears npm's cache after the global upgrade to prevent stale entries interfering with the subsequent `npm ci`. |
|
||||
|
||||
### 3.4 Caveats
|
||||
|
||||
**"0 Fixable" status:** Docker Scout reports zero fixable CVEs across all 9 at research time (2026-03-16), meaning patched npm packages are not yet in the registry. The `npm install -g npm@${NPM_VERSION}` step is **defensive** — it will self-apply patches as soon as the npm team publishes a release bundling fixed dependencies. When that release is published, Renovate will propose a bump to `NPM_VERSION` which is all that is needed.
|
||||
|
||||
**Definitive fix:** A new `node:24.x.y-alpine` image from the Node.js release team (bundling a fixed npm version) is the complete resolution. Renovate auto-detects and proposes this update.
|
||||
|
||||
**`npm ci` behavior:** `npm ci` installs project dependencies from `frontend/package-lock.json` and is unaffected by upgrading the global npm executable. The frontend project's own `node_modules` are separate from npm's internal bundled packages.
|
||||
|
||||
**npm pinning:** `npm@latest` has been replaced with a top-level `ARG NPM_VERSION=11.11.1` tracked by a Renovate npm datasource comment. The ARG is declared in the Pinned Toolchain Versions block alongside `GO_VERSION`, `XNET_VERSION`, etc. Renovate auto-proposes version bumps when a newer npm release is published. The implemented pattern:
|
||||
|
||||
```dockerfile
|
||||
# renovate: datasource=npm depName=npm
|
||||
ARG NPM_VERSION=11.11.1
|
||||
# hadolint ignore=DL3017
|
||||
RUN apk upgrade --no-cache && \
|
||||
npm install -g npm@${NPM_VERSION} --no-fund --no-audit && \
|
||||
npm cache clean --force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Phase 1: Playwright Tests
|
||||
|
||||
No new Playwright tests are required. The change is entirely in the Docker build process, not in application behavior. The E2E suite exercises the running application and does not validate build-stage CVEs.
|
||||
|
||||
### Phase 2: Dockerfile Change
|
||||
|
||||
1. Open `Dockerfile`.
|
||||
2. In the `# ---- Pinned Toolchain Versions ----` section (approximately line 27), locate `ARG XNET_VERSION` and insert the `NPM_VERSION` ARG immediately after it, as specified in §3.2 Change A.
|
||||
3. Locate the `# ---- Frontend Builder ----` comment block (approximately line 88).
|
||||
4. Find the line `# Vite 8: Rolldown native bindings auto-resolved per platform via optionalDependencies`.
|
||||
5. After that line, insert the new RUN block exactly as specified in §3.2 Change B.
|
||||
6. Leave all other lines in the `frontend-builder` stage unchanged.
|
||||
|
||||
### Phase 3: Build Verification
|
||||
|
||||
```bash
|
||||
# Build frontend-builder stage only (fast, ~2 min)
|
||||
docker build --target frontend-builder -t charon-frontend-builder-test .
|
||||
|
||||
# Confirm npm was upgraded (version should be newer than shipped with node:24.14.0-alpine)
|
||||
docker run --rm charon-frontend-builder-test npm --version
|
||||
|
||||
# Grype scan of the built stage
|
||||
grype charon-frontend-builder-test --fail-on high
|
||||
|
||||
# Trivy scan
|
||||
trivy image --severity HIGH,CRITICAL --exit-code 1 charon-frontend-builder-test
|
||||
```
|
||||
|
||||
If patched npm packages are in the registry, Grype and Trivy will report 0 HIGH CVEs for npm packages. If patches are not yet published, both scanners will still report the 6 HIGH CVEs (the `npm@${NPM_VERSION}` step installs `11.11.1`; once the npm team ships a patched release, Renovate bumps `NPM_VERSION` to pick it up).
|
||||
|
||||
### Phase 4: Full Image Build
|
||||
|
||||
```bash
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t charon:test .
|
||||
```
|
||||
|
||||
Confirm the final runtime image does not inherit the build-stage CVEs:
|
||||
|
||||
```bash
|
||||
docker scout cves charon:test
|
||||
```
|
||||
|
||||
### Phase 5: Monitor Renovate
|
||||
|
||||
No action required. Renovate monitors `node` on Docker Hub via the existing `# renovate: datasource=docker depName=node` comment. When `node:24.15.0-alpine` lands, Renovate opens a PR.
|
||||
|
||||
---
|
||||
|
||||
## 5. Commit Slicing Strategy
|
||||
|
||||
**Decision: Single PR.**
|
||||
|
||||
The entire change is one file (`Dockerfile`), one stage, three lines added. There are no application code changes, no schema changes, no test changes. A single commit and single PR is appropriate.
|
||||
|
||||
### PR-1 — `fix: upgrade npm and apk in frontend-builder to mitigate CVEs`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Branch | `fix/node-alpine-cve-remediation` |
|
||||
| Files changed | `Dockerfile` (1 file, ~4 lines added) |
|
||||
| Dependencies | None |
|
||||
| Rollback | `git revert HEAD` on the merge commit |
|
||||
|
||||
**Suggested commit message:**
|
||||
|
||||
```
|
||||
fix: upgrade npm and apk in frontend-builder to mitigate node CVEs
|
||||
|
||||
The node:24.14.0-alpine image used in the frontend-builder stage
|
||||
carries 6 HIGH-severity CVEs in npm's internally-bundled packages:
|
||||
|
||||
minimatch@10.1.2: CVE-2026-26996 (8.7), CVE-2026-27904 (7.5),
|
||||
CVE-2026-27903 (7.5)
|
||||
tar@7.5.7: CVE-2026-29786 (8.2), CVE-2026-31802 (8.2),
|
||||
CVE-2026-26960 (7.1)
|
||||
|
||||
Plus 2 medium and 1 low Alpine CVEs in busybox and zlib.
|
||||
|
||||
No newer node:24.x-alpine image exists on Docker Hub as of 2026-03-16.
|
||||
node:24-alpine resolves to the same multi-arch index digest as the
|
||||
pinned 24.14.0-alpine tag. Renovate will auto-update the FROM line
|
||||
when node:24.15.0-alpine is published.
|
||||
|
||||
Add a pre-npm-ci RUN step in frontend-builder to:
|
||||
- Run `apk upgrade --no-cache` to pick up Alpine package patches for
|
||||
busybox/zlib as soon as they land in the Alpine repos
|
||||
- Run `npm install -g npm@${NPM_VERSION}` (pinned to `11.11.1`,
|
||||
Renovate-tracked via npm datasource) to replace npm's bundled
|
||||
minimatch and tar with patched versions once npm publishes a fix;
|
||||
Renovate auto-proposes NPM_VERSION bumps when newer releases land
|
||||
|
||||
The frontend-builder stage does not appear in the final runtime image
|
||||
so runtime risk is zero; this change targets supply chain security.
|
||||
```
|
||||
|
||||
**Validation gate:** Docker build exits 0; Grype/Trivy scans of the `frontend-builder` target report 0 HIGH CVEs for npm packages (contingent on npm publishing patched releases).
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance Criteria
|
||||
|
||||
| # | Criterion | How to verify |
|
||||
|---|---|---|
|
||||
| 1 | Docker build succeeds for `linux/amd64` and `linux/arm64` | `docker buildx build --platform linux/amd64,linux/arm64 --target frontend-builder .` exits 0 |
|
||||
| 2 | No new CVEs introduced | Grype scan of the new build shows no CVEs not already present in the baseline |
|
||||
| 3 | `apk upgrade` runs without error | Build log shows apk output without error exit |
|
||||
| 4 | npm version is upgraded | `docker run --rm charon-frontend-builder-test npm --version` shows a version newer than what shipped with node:24.14.0-alpine |
|
||||
| 5 | `npm ci` still succeeds | Build log shows successful `npm ci` after the upgrade step |
|
||||
| 6 | Final runtime image is unaffected | `docker scout cves charon:latest` shows no increase in CVE count vs pre-change baseline |
|
||||
| 7 | Renovate comment preserved | `# renovate: datasource=docker depName=node` remains on the line immediately before the `FROM` |
|
||||
| 8 | Diagnostic shows 0 HIGH npm CVEs | Grype/Trivy scan of `frontend-builder` target exits 0 with `--fail-on high` once npm publishes patched minimatch/tar |
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Questions / Future Work
|
||||
|
||||
1. **When will `node:24.15.0-alpine` be released?** Node.js 24.x follows a roughly bi-weekly release cadence. Monitor https://github.com/nodejs/node/releases. Renovate handles the FROM update automatically once the image is on Docker Hub.
|
||||
|
||||
2. ~~**Pin npm version?**~~ Resolved. `npm@latest` has been replaced with a pinned `ARG NPM_VERSION=11.11.1` in the Pinned Toolchain Versions block, tracked by Renovate's npm datasource manager. No follow-up PR is required.
|
||||
|
||||
3. **Should `node:24-alpine3.22` be evaluated?** Switching Alpine base versions to 3.22 would produce a different CVE profile but is inconsistent with the final runtime stage already using `alpine:3.23.3`. Not recommended.
|
||||
368
docs/reports/qa_report_pushover_notifications.md
Normal file
368
docs/reports/qa_report_pushover_notifications.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# QA & Security Audit Report — Pushover Notification Provider
|
||||
|
||||
**Date:** 2026-03-16
|
||||
**Scope:** Pushover notification provider full-stack implementation
|
||||
**Auditor:** QA/Security Review
|
||||
**Verdict:** ✅ PASS with one test fix applied (see FE-001 below)
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope of Changes Reviewed
|
||||
|
||||
| Area | Files |
|
||||
|------|-------|
|
||||
| Backend – feature flags | `backend/internal/notifications/feature_flags.go` |
|
||||
| Backend – router | `backend/internal/notifications/router.go` |
|
||||
| Backend – notification service | `backend/internal/services/notification_service.go` |
|
||||
| Backend – enhanced security service | `backend/internal/services/enhanced_security_notification_service.go` |
|
||||
| Backend – handler (CRUD + Test guards) | `backend/internal/api/handlers/notification_provider_handler.go` |
|
||||
| Backend – unit tests (~10 new test cases) | `backend/internal/services/notification_service_test.go` |
|
||||
| Frontend – form fields | `frontend/src/pages/Notifications.tsx` |
|
||||
| Frontend – supported types | `frontend/src/api/notifications.ts` |
|
||||
| Frontend – i18n | `frontend/src/locales/en/translation.json` |
|
||||
| Frontend – unit tests | `frontend/src/pages/__tests__/Notifications.test.tsx` |
|
||||
| Model | `backend/internal/models/notification_provider.go` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Required Checks — Results
|
||||
|
||||
### 2.1 Backend Compilation
|
||||
|
||||
```
|
||||
cd /projects/Charon/backend && go build ./...
|
||||
```
|
||||
|
||||
**Result: ✅ PASS** — Zero compilation errors across all packages.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Backend Unit Tests
|
||||
|
||||
```
|
||||
cd /projects/Charon/backend && go test ./...
|
||||
```
|
||||
|
||||
**Result: ✅ PASS** — All 33 packages pass with no failures.
|
||||
|
||||
| Package | Status |
|
||||
|---------|--------|
|
||||
| `internal/api/handlers` | ok (66.1s) |
|
||||
| `internal/services` | ok (75.4s) |
|
||||
| `internal/notifications` | ok (cached) |
|
||||
| All other packages | ok |
|
||||
|
||||
Pushover-specific tests (10 cases, all PASS):
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| `TestPushoverDispatch_Success` | PASS |
|
||||
| `TestPushoverDispatch_MissingToken` | PASS |
|
||||
| `TestPushoverDispatch_MissingUserKey` | PASS |
|
||||
| `TestPushoverDispatch_MessageFieldRequired` | PASS |
|
||||
| `TestPushoverDispatch_EmergencyPriorityRejected` | PASS |
|
||||
| `TestPushoverDispatch_PayloadInjection` | PASS |
|
||||
| `TestPushoverDispatch_FeatureFlagDisabled` | PASS |
|
||||
| `TestPushoverDispatch_SSRFValidation` | PASS |
|
||||
| `TestIsDispatchEnabled_PushoverDefaultTrue` | PASS |
|
||||
| `TestIsDispatchEnabled_PushoverDisabledByFlag` | PASS |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Backend Linting
|
||||
|
||||
```
|
||||
cd /projects/Charon && make lint-fast
|
||||
```
|
||||
|
||||
**Result: ✅ PASS** — `0 issues.` (staticcheck, govet, errcheck, ineffassign, unused)
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Frontend TypeScript Check
|
||||
|
||||
```
|
||||
cd /projects/Charon/frontend && npx tsc --noEmit
|
||||
```
|
||||
|
||||
**Result: ✅ PASS** — No TypeScript errors.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Frontend Unit Tests
|
||||
|
||||
```
|
||||
cd /projects/Charon/frontend && npx vitest run
|
||||
```
|
||||
|
||||
**Result: ✅ PASS (after fix applied — see FE-001)**
|
||||
|
||||
| Test File | Tests | Status |
|
||||
|-----------|-------|--------|
|
||||
| `SecurityNotificationSettingsModal.test.tsx` | 4 | ✅ PASS |
|
||||
| `Notifications.test.tsx` | 34 | ✅ PASS |
|
||||
| `notifications.test.ts` (API layer) | 4 | ✅ PASS |
|
||||
|
||||
Pushover-specific frontend tests confirmed in `Notifications.test.tsx`:
|
||||
- `renders pushover form with API Token field and User Key placeholder` — PASS
|
||||
- Provider type select includes `'pushover'` in options array — PASS
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Pre-commit / Lefthook Hooks
|
||||
|
||||
**Result: ⚠️ N/A** — The project uses Lefthook (`lefthook.yml`), not pre-commit native. No `.pre-commit-config.yaml` is present. Running the `pre-commit` binary directly raises `InvalidConfigError: .pre-commit-config.yaml is not a file`. Code hygiene was verified manually in changed files; no whitespace or formatting issues were found.
|
||||
|
||||
---
|
||||
|
||||
### 2.7 Trivy Filesystem Security Scan
|
||||
|
||||
```
|
||||
cd /projects/Charon && .github/skills/scripts/skill-runner.sh security-scan-trivy
|
||||
```
|
||||
|
||||
**Result: ✅ PASS** — No vulnerabilities or secrets detected.
|
||||
|
||||
```
|
||||
Report Summary
|
||||
┌────────────────────────────────┬───────┬─────────────────┬─────────┐
|
||||
│ Target │ Type │ Vulnerabilities │ Secrets │
|
||||
├────────────────────────────────┼───────┼─────────────────┼─────────┤
|
||||
│ backend/go.mod │ gomod │ 0 │ - │
|
||||
├────────────────────────────────┼───────┼─────────────────┼─────────┤
|
||||
│ frontend/package-lock.json │ npm │ 0 │ - │
|
||||
├────────────────────────────────┼───────┼─────────────────┼─────────┤
|
||||
│ package-lock.json │ npm │ 0 │ - │
|
||||
├────────────────────────────────┼───────┼─────────────────┼─────────┤
|
||||
│ playwright/.auth/user.json │ text │ - │ 0 │
|
||||
└────────────────────────────────┴───────┴─────────────────┴─────────┘
|
||||
[SUCCESS] Trivy scan completed - no issues found
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Regression — Services Package
|
||||
|
||||
```
|
||||
cd /projects/Charon/backend && go test ./internal/services/... -v 2>&1 | grep -E "^(--- PASS|--- FAIL|FAIL|ok)"
|
||||
```
|
||||
|
||||
**Result: ✅ PASS** — All existing service tests continue to pass; no regressions introduced.
|
||||
|
||||
---
|
||||
|
||||
### 2.9 Regression — Handlers Package
|
||||
|
||||
```
|
||||
cd /projects/Charon/backend && go test ./internal/api/handlers/... -v 2>&1 | grep -E "^(--- PASS|--- FAIL|FAIL|ok)"
|
||||
```
|
||||
|
||||
**Result: ✅ PASS** — All existing handler tests continue to pass; no regressions introduced.
|
||||
|
||||
---
|
||||
|
||||
## 3. Security Code Review
|
||||
|
||||
### 3.1 Token JSON Serialization (`json:"-"`)
|
||||
|
||||
**Model field** (`backend/internal/models/notification_provider.go`):
|
||||
```go
|
||||
Token string `json:"-"` // Auth token for providers — never exposed in API
|
||||
```
|
||||
|
||||
**Finding: ✅ SECURE**
|
||||
|
||||
The `Token` field on `models.NotificationProvider` carries `json:"-"`, preventing it from being marshalled into any JSON response. Handler-level defense-in-depth also explicitly clears the token before responding in `List`, `Create`, and `Update`:
|
||||
|
||||
```go
|
||||
provider.HasToken = provider.Token != ""
|
||||
provider.Token = ""
|
||||
c.JSON(http.StatusOK, provider)
|
||||
```
|
||||
|
||||
Two independent layers prevent token leakage.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 SSRF Hostname Pin (`api.pushover.net`)
|
||||
|
||||
**Production dispatch path** (`notification_service.go`):
|
||||
```go
|
||||
pushoverBase := s.pushoverAPIBaseURL // always "https://api.pushover.net" in production
|
||||
dispatchURL = pushoverBase + "/1/messages.json"
|
||||
|
||||
parsedURL, parseErr := neturl.Parse(dispatchURL)
|
||||
expectedHost := "api.pushover.net"
|
||||
// test-seam bypass: only applies when pushoverAPIBaseURL has been overridden in tests
|
||||
if parsedURL != nil && parsedURL.Hostname() != "" && pushoverBase != "https://api.pushover.net" {
|
||||
expectedHost = parsedURL.Hostname()
|
||||
}
|
||||
if parseErr != nil || parsedURL.Hostname() != expectedHost {
|
||||
return fmt.Errorf("pushover dispatch URL validation failed: invalid hostname")
|
||||
}
|
||||
```
|
||||
|
||||
**Finding: ✅ SECURE**
|
||||
|
||||
In production, `pushoverAPIBaseURL` is always `"https://api.pushover.net"` (set in `NewNotificationService`). The bypass condition `pushoverBase != "https://api.pushover.net"` is only true in unit tests where the field is overridden via direct struct access (`svc.pushoverAPIBaseURL = server.URL`). This field is:
|
||||
|
||||
- A private Go struct field — cannot be set via any API endpoint
|
||||
- Absent from `notificationProviderUpsertRequest` and `notificationProviderTestRequest`
|
||||
- Identical in design to the existing Telegram SSRF pin (reviewed previously)
|
||||
|
||||
No user-supplied input can influence the dispatch hostname.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Template Injection — `token`/`user` Field Override
|
||||
|
||||
**Dispatch logic** (`notification_service.go`):
|
||||
```go
|
||||
// Template payload is rendered, then server-side values OVERWRITE any user-supplied keys:
|
||||
jsonPayload["token"] = decryptedToken // from DB
|
||||
jsonPayload["user"] = p.URL // from DB
|
||||
```
|
||||
|
||||
**Finding: ✅ SECURE**
|
||||
|
||||
Server-side values always overwrite any `token` or `user` keys that may have been injected via the provider's Config template. This is explicitly exercised by `TestPushoverDispatch_PayloadInjection`, which confirms that a template containing `"token": "fake-token", "user": "fake-user"` is replaced with the real decrypted DB values before the outbound HTTP request is made.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Emergency Priority=2 Rejection
|
||||
|
||||
**Validation** (`notification_service.go`):
|
||||
```go
|
||||
if priority, ok := jsonPayload["priority"]; ok {
|
||||
if p, isFloat := priority.(float64); isFloat && p == 2 {
|
||||
return fmt.Errorf("pushover emergency priority (2) requires retry and expire parameters; not yet supported")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Finding: ✅ CORRECT**
|
||||
|
||||
Emergency priority (2) is blocked with a clear, actionable error. JSON numbers are decoded as `float64` by `json.Unmarshal`, so the `p == 2` comparison is type-safe. Non-emergency priorities (-2, -1, 0, 1) pass through. The comparison `float64(2) == 2` evaluates correctly in Go.
|
||||
|
||||
Covered by `TestPushoverDispatch_EmergencyPriorityRejected`.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Test() Write-Only Guard — Pushover and Telegram
|
||||
|
||||
**Handler** (`notification_provider_handler.go`):
|
||||
```go
|
||||
if providerType == "pushover" && strings.TrimSpace(req.Token) != "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation",
|
||||
"Pushover API token is accepted only on provider create/update")
|
||||
return
|
||||
}
|
||||
|
||||
if providerType == "telegram" && strings.TrimSpace(req.Token) != "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation",
|
||||
"Telegram bot token is accepted only on provider create/update")
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Finding: ✅ CORRECT**
|
||||
|
||||
Passing a token in the `Test` request body is rejected with HTTP 400 `TOKEN_WRITE_ONLY` for both Pushover and Telegram. The test dispatch always reads credentials from the database (provider ID is required), preventing token exfiltration or injection via the test endpoint. The same guard exists for Gotify and Slack, maintaining symmetry across all token-based providers.
|
||||
|
||||
---
|
||||
|
||||
### 3.6 `pushoverAPIBaseURL` Accessibility via API
|
||||
|
||||
**Finding: ✅ SECURE**
|
||||
|
||||
`pushoverAPIBaseURL` is a private struct field with no API exposure:
|
||||
1. Not exported from the `NotificationService` struct
|
||||
2. Not present in any request struct unmarshalled from user input
|
||||
3. Only modified in test code via `svc.pushoverAPIBaseURL = server.URL`
|
||||
4. Never read from user input, headers, query parameters, or provider Config
|
||||
|
||||
Production dispatches invariably target `https://api.pushover.net/1/messages.json`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Findings
|
||||
|
||||
### FE-001 — Stale Test Assertion After Adding Pushover Provider Type
|
||||
|
||||
**Severity:** 🟡 MEDIUM (test failure, blocks CI)
|
||||
**File:** `frontend/src/components/__tests__/SecurityNotificationSettingsModal.test.tsx:89`
|
||||
**Status:** ✅ **FIXED**
|
||||
|
||||
**Description:** After Pushover was added to `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` in `notifications.ts`, the assertion checking the provider type dropdown options was not updated. The test expected 6 types but the implementation exposes 7, causing the test to fail and block CI.
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
expect(Array.from(typeSelect.options).map((option) => option.value))
|
||||
.toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack']);
|
||||
```
|
||||
|
||||
**After (applied):**
|
||||
```typescript
|
||||
expect(Array.from(typeSelect.options).map((option) => option.value))
|
||||
.toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover']);
|
||||
```
|
||||
|
||||
All 4 tests in `SecurityNotificationSettingsModal.test.tsx` pass after the fix.
|
||||
|
||||
---
|
||||
|
||||
### BE-001 — No Handler-Level Unit Tests for Pushover TOKEN_WRITE_ONLY Guard
|
||||
|
||||
**Severity:** 🟢 LOW (coverage gap, not a functional defect)
|
||||
**File:** `backend/internal/api/handlers/notification_provider_handler_test.go`
|
||||
**Status:** ⚠️ INFORMATIONAL
|
||||
|
||||
**Description:** The `Test()` handler's Pushover `TOKEN_WRITE_ONLY` guard is correctly implemented and is structurally identical to the existing Gotify, Slack, and Telegram guards. The guard is verified at the code-review level but no dedicated handler integration test exercises it. This gap applies to all four token-based providers, not Pushover in isolation.
|
||||
|
||||
**Recommendation:** Add handler integration tests for `TOKEN_WRITE_ONLY` guards across Gotify, Telegram, Slack, and Pushover in a follow-up issue to achieve symmetrical handler coverage.
|
||||
|
||||
---
|
||||
|
||||
### E2E-001 — No Playwright E2E Spec for Pushover Provider
|
||||
|
||||
**Severity:** 🟢 LOW (coverage gap)
|
||||
**Status:** ⚠️ INFORMATIONAL
|
||||
|
||||
**Description:** The implementation scope stated "New E2E spec" but no Playwright `.spec.ts` file for Pushover was found in the repository. The `playwright/` directory contains only the auth fixture. Frontend unit tests (`Notifications.test.tsx`) provide partial coverage of the form rendering path, but there is no browser-level test exercising the full add/edit/test flow for Pushover.
|
||||
|
||||
**Recommendation:** Create a Playwright spec covering: add Pushover provider, verify "User Key" and "API Token (Application)" field labels, test provider response handling. Target the next release cycle.
|
||||
|
||||
---
|
||||
|
||||
### SEC-001 — SSRF Test Bypass Pattern (Design Note)
|
||||
|
||||
**Severity:** ✅ INFORMATIONAL (no action required)
|
||||
|
||||
**Description:** The `pushoverAPIBaseURL` field allows the SSRF pin to be bypassed in test environments. This is intentional, mirrors the existing Telegram test-seam pattern, and is not exploitable via any API vector. Documented for audit trail completeness.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| Backend compilation (`go build ./...`) | ✅ PASS |
|
||||
| Backend unit tests (`go test ./...`) | ✅ PASS |
|
||||
| Backend linting (`make lint-fast`) | ✅ PASS |
|
||||
| Frontend TypeScript (`tsc --noEmit`) | ✅ PASS |
|
||||
| Frontend unit tests (`vitest run`) | ✅ PASS (after FE-001 fix) |
|
||||
| Pre-commit hooks | ⚠️ N/A (project uses Lefthook) |
|
||||
| Trivy filesystem scan | ✅ PASS — 0 vulns, 0 secrets |
|
||||
| Regression — services package | ✅ PASS |
|
||||
| Regression — handlers package | ✅ PASS |
|
||||
| `Token` field `json:"-"` guard | ✅ SECURE |
|
||||
| SSRF hostname pin (`api.pushover.net`) | ✅ SECURE |
|
||||
| Template injection guard | ✅ SECURE |
|
||||
| Emergency priority=2 rejection | ✅ CORRECT |
|
||||
| Test() write-only guard (Pushover + Telegram) | ✅ CORRECT |
|
||||
| `pushoverAPIBaseURL` API inaccessibility | ✅ SECURE |
|
||||
|
||||
**Critical/High security findings: 0**
|
||||
**Total findings: 4** (1 fixed, 3 informational coverage gaps)
|
||||
|
||||
The Pushover notification provider implementation is secure and functionally correct. The one blocking defect (FE-001) was identified and resolved during this audit. The three remaining findings are non-blocking coverage gaps with no security impact and no CVE surface.
|
||||
136
frontend/package-lock.json
generated
136
frontend/package-lock.json
generated
@@ -45,9 +45,9 @@
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
||||
"@typescript-eslint/parser": "^8.57.0",
|
||||
"@typescript-eslint/utils": "^8.57.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
||||
"@typescript-eslint/parser": "^8.57.1",
|
||||
"@typescript-eslint/utils": "^8.57.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-istanbul": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
@@ -73,7 +73,7 @@
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^6.0.1-rc",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0",
|
||||
"zod-validation-error": "^5.0.0"
|
||||
@@ -3434,17 +3434,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz",
|
||||
"integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
||||
"integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.57.0",
|
||||
"@typescript-eslint/type-utils": "8.57.0",
|
||||
"@typescript-eslint/utils": "8.57.0",
|
||||
"@typescript-eslint/visitor-keys": "8.57.0",
|
||||
"@typescript-eslint/scope-manager": "8.57.1",
|
||||
"@typescript-eslint/type-utils": "8.57.1",
|
||||
"@typescript-eslint/utils": "8.57.1",
|
||||
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
@@ -3457,22 +3457,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.57.0",
|
||||
"@typescript-eslint/parser": "^8.57.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz",
|
||||
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz",
|
||||
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.57.0",
|
||||
"@typescript-eslint/types": "8.57.0",
|
||||
"@typescript-eslint/typescript-estree": "8.57.0",
|
||||
"@typescript-eslint/visitor-keys": "8.57.0",
|
||||
"@typescript-eslint/scope-manager": "8.57.1",
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3488,14 +3488,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz",
|
||||
"integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz",
|
||||
"integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.57.0",
|
||||
"@typescript-eslint/types": "^8.57.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.57.1",
|
||||
"@typescript-eslint/types": "^8.57.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3510,14 +3510,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz",
|
||||
"integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz",
|
||||
"integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.57.0",
|
||||
"@typescript-eslint/visitor-keys": "8.57.0"
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"@typescript-eslint/visitor-keys": "8.57.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3528,9 +3528,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz",
|
||||
"integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz",
|
||||
"integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3545,15 +3545,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz",
|
||||
"integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz",
|
||||
"integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.57.0",
|
||||
"@typescript-eslint/typescript-estree": "8.57.0",
|
||||
"@typescript-eslint/utils": "8.57.0",
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||
"@typescript-eslint/utils": "8.57.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.4.0"
|
||||
},
|
||||
@@ -3570,9 +3570,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz",
|
||||
"integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz",
|
||||
"integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3584,16 +3584,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz",
|
||||
"integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz",
|
||||
"integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.57.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.57.0",
|
||||
"@typescript-eslint/types": "8.57.0",
|
||||
"@typescript-eslint/visitor-keys": "8.57.0",
|
||||
"@typescript-eslint/project-service": "8.57.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.57.1",
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"@typescript-eslint/visitor-keys": "8.57.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -3612,16 +3612,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz",
|
||||
"integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz",
|
||||
"integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.57.0",
|
||||
"@typescript-eslint/types": "8.57.0",
|
||||
"@typescript-eslint/typescript-estree": "8.57.0"
|
||||
"@typescript-eslint/scope-manager": "8.57.1",
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"@typescript-eslint/typescript-estree": "8.57.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3636,13 +3636,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz",
|
||||
"integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz",
|
||||
"integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.57.0",
|
||||
"@typescript-eslint/types": "8.57.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4920,9 +4920,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz",
|
||||
"integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==",
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz",
|
||||
"integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10511,16 +10511,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz",
|
||||
"integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz",
|
||||
"integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@typescript-eslint/typescript-estree": "8.57.0",
|
||||
"@typescript-eslint/utils": "8.57.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
"@typescript-eslint/parser": "8.57.1",
|
||||
"@typescript-eslint/typescript-estree": "8.57.1",
|
||||
"@typescript-eslint/utils": "8.57.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
||||
@@ -64,9 +64,9 @@
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
||||
"@typescript-eslint/parser": "^8.57.0",
|
||||
"@typescript-eslint/utils": "^8.57.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
||||
"@typescript-eslint/parser": "^8.57.1",
|
||||
"@typescript-eslint/utils": "^8.57.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-istanbul": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
@@ -92,7 +92,7 @@
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^6.0.1-rc",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0",
|
||||
"zod-validation-error": "^5.0.0"
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('notifications api', () => {
|
||||
await testProvider({ id: '2', name: 'test', type: 'discord' })
|
||||
expect(client.post).toHaveBeenCalledWith('/notifications/providers/test', { id: '2', name: 'test', type: 'discord' })
|
||||
|
||||
await expect(createProvider({ name: 'x', type: 'pushover' })).rejects.toThrow('Unsupported notification provider type: pushover')
|
||||
await expect(createProvider({ name: 'x', type: 'pagerduty' })).rejects.toThrow('Unsupported notification provider type: pagerduty')
|
||||
await expect(updateProvider('2', { name: 'updated', type: 'generic' })).rejects.toThrow('Unsupported notification provider type: generic')
|
||||
await testProvider({ id: '2', name: 'test', type: 'telegram' })
|
||||
expect(client.post).toHaveBeenCalledWith('/notifications/providers/test', { id: '2', name: 'test', type: 'telegram' })
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
previewExternalTemplate,
|
||||
getSecurityNotificationSettings,
|
||||
updateSecurityNotificationSettings,
|
||||
SUPPORTED_NOTIFICATION_PROVIDER_TYPES,
|
||||
} from './notifications'
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
@@ -118,7 +119,7 @@ describe('notifications api', () => {
|
||||
type: 'gotify',
|
||||
})
|
||||
|
||||
await expect(createProvider({ name: 'Bad', type: 'pushover' })).rejects.toThrow('Unsupported notification provider type: pushover')
|
||||
await expect(createProvider({ name: 'Bad', type: 'sms' })).rejects.toThrow('Unsupported notification provider type: sms')
|
||||
await expect(updateProvider('bad', { type: 'generic' })).rejects.toThrow('Unsupported notification provider type: generic')
|
||||
})
|
||||
|
||||
@@ -228,4 +229,28 @@ describe('notifications api', () => {
|
||||
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/settings/security', { enabled: false, min_log_level: 'error' })
|
||||
expect(updated.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('pushover is in SUPPORTED_NOTIFICATION_PROVIDER_TYPES', () => {
|
||||
expect(SUPPORTED_NOTIFICATION_PROVIDER_TYPES).toContain('pushover')
|
||||
})
|
||||
|
||||
it('sanitizeProviderForWriteAction preserves token for pushover type', async () => {
|
||||
mockedClient.post.mockResolvedValue({ data: { id: 'po1' } })
|
||||
mockedClient.put.mockResolvedValue({ data: { id: 'po1' } })
|
||||
|
||||
await createProvider({ name: 'Pushover', type: 'pushover', gotify_token: 'app-api-token', url: 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG' })
|
||||
expect(mockedClient.post).toHaveBeenCalledWith('/notifications/providers', {
|
||||
name: 'Pushover',
|
||||
type: 'pushover',
|
||||
token: 'app-api-token',
|
||||
url: 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG',
|
||||
})
|
||||
|
||||
await updateProvider('po1', { type: 'pushover', url: 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG', gotify_token: 'new-token' })
|
||||
expect(mockedClient.put).toHaveBeenCalledWith('/notifications/providers/po1', {
|
||||
type: 'pushover',
|
||||
url: 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG',
|
||||
token: 'new-token',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import client from './client';
|
||||
|
||||
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack'] as const;
|
||||
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover'] as const;
|
||||
export type SupportedNotificationProviderType = (typeof SUPPORTED_NOTIFICATION_PROVIDER_TYPES)[number];
|
||||
const DEFAULT_PROVIDER_TYPE: SupportedNotificationProviderType = 'discord';
|
||||
|
||||
@@ -59,7 +59,7 @@ const sanitizeProviderForWriteAction = (data: Partial<NotificationProvider>): Pa
|
||||
|
||||
delete payload.gotify_token;
|
||||
|
||||
if (type !== 'gotify' && type !== 'telegram' && type !== 'slack') {
|
||||
if (type !== 'gotify' && type !== 'telegram' && type !== 'slack' && type !== 'pushover') {
|
||||
delete payload.token;
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('Security Notification Settings on Notifications page', () => {
|
||||
await user.click(await screen.findByTestId('add-provider-btn'));
|
||||
|
||||
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement;
|
||||
expect(Array.from(typeSelect.options).map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack']);
|
||||
expect(Array.from(typeSelect.options).map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover']);
|
||||
expect(typeSelect.value).toBe('discord');
|
||||
|
||||
const webhookInput = screen.getByTestId('provider-url') as HTMLInputElement;
|
||||
|
||||
@@ -591,7 +591,13 @@
|
||||
"slackWebhookUrl": "Webhook URL",
|
||||
"slackWebhookUrlPlaceholder": "https://hooks.slack.com/services/T.../B.../xxx",
|
||||
"slackChannelName": "Channel Name (optional)",
|
||||
"slackChannelNameHelp": "Display name for the channel. The actual channel is determined by the webhook configuration."
|
||||
"slackChannelNameHelp": "Display name for the channel. The actual channel is determined by the webhook configuration.",
|
||||
"pushover": "Pushover",
|
||||
"pushoverApiToken": "API Token (Application)",
|
||||
"pushoverApiTokenPlaceholder": "Enter your Pushover Application API Token",
|
||||
"pushoverUserKey": "User Key",
|
||||
"pushoverUserKeyPlaceholder": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG",
|
||||
"pushoverUserKeyHelp": "Your Pushover user or group key. The API token is stored securely and separately."
|
||||
},
|
||||
"users": {
|
||||
"title": "User Management",
|
||||
|
||||
@@ -23,7 +23,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';
|
||||
return t === 'discord' || t === 'gotify' || t === 'webhook' || t === 'telegram' || t === 'slack' || t === 'pushover';
|
||||
};
|
||||
|
||||
const isUnsupportedProviderType = (providerType: string | undefined): boolean => !isSupportedProviderType(providerType);
|
||||
@@ -43,7 +43,7 @@ const normalizeProviderPayloadForSubmit = (data: Partial<NotificationProvider>):
|
||||
type,
|
||||
};
|
||||
|
||||
if (type === 'gotify' || type === 'telegram' || type === 'slack') {
|
||||
if (type === 'gotify' || type === 'telegram' || type === 'slack' || type === 'pushover') {
|
||||
const normalizedToken = typeof payload.gotify_token === 'string' ? payload.gotify_token.trim() : '';
|
||||
|
||||
if (normalizedToken.length > 0) {
|
||||
@@ -148,9 +148,10 @@ const ProviderForm: FC<{
|
||||
const isTelegram = type === 'telegram';
|
||||
const isEmail = type === 'email';
|
||||
const isSlack = type === 'slack';
|
||||
const isPushover = type === 'pushover';
|
||||
const isNew = !watch('id');
|
||||
useEffect(() => {
|
||||
if (type !== 'gotify' && type !== 'telegram' && type !== 'slack') {
|
||||
if (type !== 'gotify' && type !== 'telegram' && type !== 'slack' && type !== 'pushover') {
|
||||
setValue('gotify_token', '', { shouldDirty: false, shouldTouch: false });
|
||||
}
|
||||
}, [type, setValue]);
|
||||
@@ -207,6 +208,7 @@ const ProviderForm: FC<{
|
||||
<option value="email">Email</option>
|
||||
<option value="telegram">{t('notificationProviders.telegram')}</option>
|
||||
<option value="slack">{t('notificationProviders.slack')}</option>
|
||||
<option value="pushover">Pushover</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +220,9 @@ const ProviderForm: FC<{
|
||||
? t('notificationProviders.telegramChatId')
|
||||
: isSlack
|
||||
? t('notificationProviders.slackChannelName')
|
||||
: <>{t('notificationProviders.urlWebhook')} <span aria-hidden="true">*</span></>}
|
||||
: isPushover
|
||||
? t('notificationProviders.pushoverUserKey')
|
||||
: <>{t('notificationProviders.urlWebhook')} <span aria-hidden="true">*</span></>}
|
||||
</label>
|
||||
{isEmail && (
|
||||
<p id="email-recipients-help" className="text-xs text-gray-500 mt-0.5">
|
||||
@@ -229,10 +233,10 @@ const ProviderForm: FC<{
|
||||
id="provider-url"
|
||||
{...register('url', {
|
||||
required: (isEmail || isSlack) ? false : (t('notificationProviders.urlRequired') as string),
|
||||
validate: (isEmail || isTelegram || isSlack) ? undefined : validateUrl,
|
||||
validate: (isEmail || isTelegram || isSlack || isPushover) ? undefined : validateUrl,
|
||||
})}
|
||||
data-testid="provider-url"
|
||||
placeholder={isEmail ? 'user@example.com, admin@example.com' : isTelegram ? '987654321' : isSlack ? '#general' : type === 'discord' ? 'https://discord.com/api/webhooks/...' : type === 'gotify' ? 'https://gotify.example.com/message' : 'https://example.com/webhook'}
|
||||
placeholder={isEmail ? 'user@example.com, admin@example.com' : isTelegram ? '987654321' : isSlack ? '#general' : isPushover ? t('notificationProviders.pushoverUserKeyPlaceholder') : type === 'discord' ? 'https://discord.com/api/webhooks/...' : type === 'gotify' ? 'https://gotify.example.com/message' : 'https://example.com/webhook'}
|
||||
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 ${errors.url ? 'border-red-500' : ''}`}
|
||||
aria-invalid={errors.url ? 'true' : 'false'}
|
||||
aria-describedby={isEmail ? 'email-recipients-help' : errors.url ? 'provider-url-error' : undefined}
|
||||
@@ -252,10 +256,10 @@ const ProviderForm: FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isGotify || isTelegram || isSlack) && (
|
||||
{(isGotify || isTelegram || isSlack || isPushover) && (
|
||||
<div>
|
||||
<label htmlFor="provider-gotify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{isSlack ? t('notificationProviders.slackWebhookUrl') : isTelegram ? t('notificationProviders.telegramBotToken') : t('notificationProviders.gotifyToken')}
|
||||
{isPushover ? t('notificationProviders.pushoverApiToken') : isSlack ? t('notificationProviders.slackWebhookUrl') : isTelegram ? t('notificationProviders.telegramBotToken') : t('notificationProviders.gotifyToken')}
|
||||
</label>
|
||||
<input
|
||||
id="provider-gotify-token"
|
||||
@@ -263,7 +267,7 @@ const ProviderForm: FC<{
|
||||
autoComplete="new-password"
|
||||
{...register('gotify_token')}
|
||||
data-testid="provider-gotify-token"
|
||||
placeholder={initialData?.has_token ? t('notificationProviders.gotifyTokenKeepPlaceholder') : isSlack ? t('notificationProviders.slackWebhookUrlPlaceholder') : isTelegram ? t('notificationProviders.telegramBotTokenPlaceholder') : t('notificationProviders.gotifyTokenPlaceholder')}
|
||||
placeholder={initialData?.has_token ? t('notificationProviders.gotifyTokenKeepPlaceholder') : isPushover ? t('notificationProviders.pushoverApiTokenPlaceholder') : isSlack ? t('notificationProviders.slackWebhookUrlPlaceholder') : isTelegram ? t('notificationProviders.telegramBotTokenPlaceholder') : t('notificationProviders.gotifyTokenPlaceholder')}
|
||||
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"
|
||||
aria-describedby={initialData?.has_token ? 'gotify-token-stored-hint' : undefined}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('react-i18next', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../api/notifications', () => ({
|
||||
SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack'],
|
||||
SUPPORTED_NOTIFICATION_PROVIDER_TYPES: ['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover'],
|
||||
getProviders: vi.fn(),
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
@@ -148,8 +148,8 @@ describe('Notifications', () => {
|
||||
const typeSelect = screen.getByTestId('provider-type') as HTMLSelectElement
|
||||
const options = Array.from(typeSelect.options)
|
||||
|
||||
expect(options).toHaveLength(6)
|
||||
expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack'])
|
||||
expect(options).toHaveLength(7)
|
||||
expect(options.map((option) => option.value)).toEqual(['discord', 'gotify', 'webhook', 'email', 'telegram', 'slack', 'pushover'])
|
||||
expect(typeSelect.disabled).toBe(false)
|
||||
})
|
||||
|
||||
@@ -428,8 +428,8 @@ describe('Notifications', () => {
|
||||
const legacyProvider: NotificationProvider = {
|
||||
...baseProvider,
|
||||
id: 'legacy-provider',
|
||||
name: 'Legacy Pushover',
|
||||
type: 'pushover',
|
||||
name: 'Legacy SMS',
|
||||
type: 'legacy_sms',
|
||||
enabled: false,
|
||||
}
|
||||
|
||||
@@ -669,4 +669,15 @@ describe('Notifications', () => {
|
||||
|
||||
expect(screen.queryByTestId('provider-url-error')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders pushover form with API Token field and User Key placeholder', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Notifications />)
|
||||
|
||||
await user.click(await screen.findByTestId('add-provider-btn'))
|
||||
await user.selectOptions(screen.getByTestId('provider-type'), 'pushover')
|
||||
|
||||
expect(screen.getByTestId('provider-gotify-token')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('provider-url')).toHaveAttribute('placeholder', 'notificationProviders.pushoverUserKeyPlaceholder')
|
||||
})
|
||||
})
|
||||
|
||||
21
tests/fixtures/notifications.ts
vendored
21
tests/fixtures/notifications.ts
vendored
@@ -21,7 +21,8 @@ export type NotificationProviderType =
|
||||
| 'telegram'
|
||||
| 'generic'
|
||||
| 'webhook'
|
||||
| 'email';
|
||||
| 'email'
|
||||
| 'pushover';
|
||||
|
||||
/**
|
||||
* Notification provider configuration interface
|
||||
@@ -171,6 +172,24 @@ export const telegramProvider: NotificationProviderConfig = {
|
||||
notify_uptime: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Pushover Provider Fixtures
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valid Pushover notification provider configuration
|
||||
*/
|
||||
export const pushoverProvider: NotificationProviderConfig = {
|
||||
name: generateProviderName('pushover'),
|
||||
type: 'pushover',
|
||||
url: 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG',
|
||||
token: 'azGDORePK8gMaC0QOYAMyEEuzJnyUi',
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Generic Webhook Provider Fixtures
|
||||
// ============================================================================
|
||||
|
||||
@@ -141,7 +141,7 @@ test.describe('Notification Providers', () => {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ id: '1', name: 'Discord Alert', type: 'discord', url: 'https://discord.com/api/webhooks/test', enabled: true },
|
||||
{ id: '2', name: 'Pushover Notify', type: 'pushover', url: 'https://hooks.example.com/services/test', enabled: true },
|
||||
{ id: '2', name: 'Pagerduty Notify', type: 'pagerduty', url: 'https://hooks.example.com/services/test', enabled: true },
|
||||
{ id: '3', name: 'Generic Hook', type: 'generic', url: 'https://webhook.test.local', enabled: false },
|
||||
]),
|
||||
});
|
||||
@@ -188,7 +188,7 @@ test.describe('Notification Providers', () => {
|
||||
body: JSON.stringify([
|
||||
{ id: '1', name: 'Discord One', type: 'discord', url: 'https://discord.com/api/webhooks/1', enabled: true },
|
||||
{ id: '2', name: 'Discord Two', type: 'discord', url: 'https://discord.com/api/webhooks/2', enabled: true },
|
||||
{ id: '3', name: 'Pushover Notify', type: 'pushover', url: 'https://hooks.example.com/test', enabled: true },
|
||||
{ id: '3', name: 'Pagerduty Notify', type: 'pagerduty', url: 'https://hooks.example.com/test', enabled: true },
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
@@ -206,7 +206,7 @@ test.describe('Notification Providers', () => {
|
||||
// Check that providers are visible - look for provider names
|
||||
await expect(page.getByText('Discord One')).toBeVisible();
|
||||
await expect(page.getByText('Discord Two')).toBeVisible();
|
||||
await expect(page.getByText('Pushover Notify')).toBeVisible();
|
||||
await expect(page.getByText('Pagerduty Notify')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify legacy provider row renders explicit deprecated messaging', async () => {
|
||||
@@ -294,8 +294,8 @@ test.describe('Notification Providers', () => {
|
||||
|
||||
await test.step('Verify provider type select contains supported options', async () => {
|
||||
const providerTypeSelect = page.getByTestId('provider-type');
|
||||
await expect(providerTypeSelect.locator('option')).toHaveCount(6);
|
||||
await expect(providerTypeSelect.locator('option')).toHaveText(['Discord', 'Gotify', 'Generic Webhook', 'Email', 'Telegram', 'Slack']);
|
||||
await expect(providerTypeSelect.locator('option')).toHaveCount(7);
|
||||
await expect(providerTypeSelect.locator('option')).toHaveText(['Discord', 'Gotify', 'Generic Webhook', 'Email', 'Telegram', 'Slack', 'Pushover']);
|
||||
await expect(providerTypeSelect).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
606
tests/settings/pushover-notification-provider.spec.ts
Normal file
606
tests/settings/pushover-notification-provider.spec.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* Pushover Notification Provider E2E Tests
|
||||
*
|
||||
* Tests the Pushover notification provider type.
|
||||
* Covers form rendering, CRUD operations, payload contracts,
|
||||
* token security, and validation behavior specific to the Pushover provider type.
|
||||
*/
|
||||
|
||||
import { test, expect, loginUser } from '../fixtures/auth-fixtures';
|
||||
import { waitForLoadingComplete } from '../utils/wait-helpers';
|
||||
|
||||
function generateProviderName(prefix: string = 'pushover-test'): string {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
test.describe('Pushover Notification Provider', () => {
|
||||
test.beforeEach(async ({ page, adminUser }) => {
|
||||
await loginUser(page, adminUser);
|
||||
await waitForLoadingComplete(page);
|
||||
await page.goto('/settings/notifications');
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
test.describe('Form Rendering', () => {
|
||||
test('should show API token field and user key placeholder when pushover type selected', async ({ page }) => {
|
||||
await test.step('Open Add Provider form', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Select pushover provider type', async () => {
|
||||
await page.getByTestId('provider-type').selectOption('pushover');
|
||||
});
|
||||
|
||||
await test.step('Verify API token field is visible', async () => {
|
||||
await expect(page.getByTestId('provider-gotify-token')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify token field label shows API Token (Application)', async () => {
|
||||
const tokenLabel = page.getByText(/api token.*application/i);
|
||||
await expect(tokenLabel.first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify user key placeholder', async () => {
|
||||
const urlInput = page.getByTestId('provider-url');
|
||||
await expect(urlInput).toHaveAttribute('placeholder', 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG');
|
||||
});
|
||||
|
||||
await test.step('Verify User Key label replaces URL label', async () => {
|
||||
const userKeyLabel = page.getByText(/user key/i);
|
||||
await expect(userKeyLabel.first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify JSON template section is shown for pushover', async () => {
|
||||
await expect(page.getByTestId('provider-config')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Verify save button is accessible', async () => {
|
||||
const saveButton = page.getByTestId('provider-save-btn');
|
||||
await expect(saveButton).toBeVisible();
|
||||
await expect(saveButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should toggle form fields correctly when switching between pushover and discord', async ({ page }) => {
|
||||
await test.step('Open Add Provider form', async () => {
|
||||
await page.getByRole('button', { name: /add.*provider/i }).click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify discord is default without token field', async () => {
|
||||
await expect(page.getByTestId('provider-type')).toHaveValue('discord');
|
||||
await expect(page.getByTestId('provider-gotify-token')).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step('Switch to pushover and verify token field appears', async () => {
|
||||
await page.getByTestId('provider-type').selectOption('pushover');
|
||||
await expect(page.getByTestId('provider-gotify-token')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Switch back to discord and verify token field hidden', async () => {
|
||||
await page.getByTestId('provider-type').selectOption('discord');
|
||||
await expect(page.getByTestId('provider-gotify-token')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show JSON template section for pushover', async ({ page }) => {
|
||||
await test.step('Open Add Provider form and select pushover', 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('pushover');
|
||||
});
|
||||
|
||||
await test.step('Verify JSON template config section is visible', async () => {
|
||||
await expect(page.getByTestId('provider-config')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('CRUD Operations', () => {
|
||||
test('should create a pushover notification provider', async ({ page }) => {
|
||||
const providerName = generateProviderName('po-create');
|
||||
let capturedPayload: Record<string, unknown> | null = null;
|
||||
|
||||
await test.step('Mock create endpoint to capture payload', async () => {
|
||||
const createdProviders: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const payload = (await request.postDataJSON()) as Record<string, unknown>;
|
||||
capturedPayload = payload;
|
||||
const { token, gotify_token, ...rest } = payload;
|
||||
const created: Record<string, unknown> = {
|
||||
id: 'po-provider-1',
|
||||
...rest,
|
||||
...(token !== undefined || gotify_token !== undefined ? { has_token: true } : {}),
|
||||
};
|
||||
createdProviders.push(created);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(created),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createdProviders),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Open form and select pushover type', 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('pushover');
|
||||
});
|
||||
|
||||
await test.step('Fill pushover provider form', async () => {
|
||||
await page.getByTestId('provider-name').fill(providerName);
|
||||
await page.getByTestId('provider-url').fill('uQiRzpo4DXghDmr9QzzfQu27cmVRsG');
|
||||
await page.getByTestId('provider-gotify-token').fill('azGDORePK8gMaC0QOYAMyEEuzJnyUi');
|
||||
});
|
||||
|
||||
await test.step('Configure event notifications', async () => {
|
||||
await page.getByTestId('notify-proxy-hosts').check();
|
||||
await page.getByTestId('notify-certs').check();
|
||||
});
|
||||
|
||||
await test.step('Save provider', async () => {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/v1\/notifications\/providers/.test(resp.url()) &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201
|
||||
),
|
||||
page.getByTestId('provider-save-btn').click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify provider appears in list', async () => {
|
||||
const providerInList = page.getByText(providerName);
|
||||
await expect(providerInList.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Verify outgoing payload contract', async () => {
|
||||
expect(capturedPayload).toBeTruthy();
|
||||
expect(capturedPayload?.type).toBe('pushover');
|
||||
expect(capturedPayload?.name).toBe(providerName);
|
||||
expect(capturedPayload?.url).toBe('uQiRzpo4DXghDmr9QzzfQu27cmVRsG');
|
||||
expect(capturedPayload?.token).toBe('azGDORePK8gMaC0QOYAMyEEuzJnyUi');
|
||||
expect(capturedPayload?.gotify_token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('should edit provider and preserve token when token field left blank', async ({ page }) => {
|
||||
let updatedPayload: Record<string, unknown> | null = null;
|
||||
|
||||
await test.step('Mock existing pushover provider', async () => {
|
||||
let providers = [
|
||||
{
|
||||
id: 'po-edit-id',
|
||||
name: 'Pushover Alerts',
|
||||
type: 'pushover',
|
||||
url: 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG',
|
||||
has_token: true,
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: false,
|
||||
},
|
||||
];
|
||||
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(providers),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/notifications/providers/*', async (route, request) => {
|
||||
if (request.method() === 'PUT') {
|
||||
updatedPayload = (await request.postDataJSON()) as Record<string, unknown>;
|
||||
providers = providers.map((p) =>
|
||||
p.id === 'po-edit-id' ? { ...p, ...updatedPayload } : p
|
||||
);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload to get mocked provider', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify pushover provider is displayed', async () => {
|
||||
await expect(page.getByText('Pushover Alerts')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Click edit on pushover provider', async () => {
|
||||
const providerRow = page.getByTestId('provider-row-po-edit-id');
|
||||
const editButton = providerRow.getByRole('button', { name: /edit/i });
|
||||
await expect(editButton).toBeVisible({ timeout: 5000 });
|
||||
await editButton.click();
|
||||
await expect(page.getByTestId('provider-name')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
await test.step('Verify form loads with pushover type', async () => {
|
||||
await expect(page.getByTestId('provider-type')).toHaveValue('pushover');
|
||||
});
|
||||
|
||||
await test.step('Verify stored token indicator is shown', async () => {
|
||||
await expect(page.getByTestId('gotify-token-stored-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Update name without changing token', async () => {
|
||||
const nameInput = page.getByTestId('provider-name');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill('Pushover Alerts v2');
|
||||
});
|
||||
|
||||
await test.step('Save changes', async () => {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/v1\/notifications\/providers\/po-edit-id/.test(resp.url()) &&
|
||||
resp.request().method() === 'PUT' &&
|
||||
resp.status() === 200
|
||||
),
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/v1\/notifications\/providers/.test(resp.url()) &&
|
||||
resp.request().method() === 'GET' &&
|
||||
resp.status() === 200
|
||||
),
|
||||
page.getByTestId('provider-save-btn').click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify update payload preserves token omission', async () => {
|
||||
expect(updatedPayload).toBeTruthy();
|
||||
expect(updatedPayload?.type).toBe('pushover');
|
||||
expect(updatedPayload?.name).toBe('Pushover Alerts v2');
|
||||
expect(updatedPayload?.token).toBeUndefined();
|
||||
expect(updatedPayload?.gotify_token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('should test a pushover notification provider', async ({ page }) => {
|
||||
let testCalled = false;
|
||||
|
||||
await test.step('Mock existing pushover provider and test endpoint', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 'po-test-id',
|
||||
name: 'Pushover Test Provider',
|
||||
type: 'pushover',
|
||||
url: 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG',
|
||||
has_token: true,
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: false,
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/notifications/providers/test', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
testCalled = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload to get mocked provider', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Click Send Test on the provider', async () => {
|
||||
const providerRow = page.getByTestId('provider-row-po-test-id');
|
||||
const sendTestButton = providerRow.getByRole('button', { name: /send test/i });
|
||||
await expect(sendTestButton).toBeVisible({ timeout: 5000 });
|
||||
await expect(sendTestButton).toBeEnabled();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes('/api/v1/notifications/providers/test') &&
|
||||
resp.status() === 200
|
||||
),
|
||||
sendTestButton.click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify test was called', async () => {
|
||||
expect(testCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should delete a pushover notification provider', async ({ page }) => {
|
||||
await test.step('Mock existing pushover provider', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 'po-delete-id',
|
||||
name: 'Pushover To Delete',
|
||||
type: 'pushover',
|
||||
url: 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG',
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/notifications/providers/*', async (route, request) => {
|
||||
if (request.method() === 'DELETE') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload to get mocked provider', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify pushover provider is displayed', async () => {
|
||||
await expect(page.getByText('Pushover To Delete')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Delete provider', async () => {
|
||||
page.on('dialog', async (dialog) => {
|
||||
expect(dialog.type()).toBe('confirm');
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
const deleteButton = page.getByRole('button', { name: /delete/i })
|
||||
.or(page.locator('button').filter({ has: page.locator('svg.lucide-trash2, svg[class*="trash"]') }));
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes('/api/v1/notifications/providers/po-delete-id') &&
|
||||
resp.status() === 200
|
||||
),
|
||||
deleteButton.first().click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify deletion feedback', async () => {
|
||||
const successIndicator = page.locator('[data-testid="toast-success"]')
|
||||
.or(page.getByRole('status').filter({ hasText: /deleted|removed/i }))
|
||||
.or(page.getByText(/no.*providers/i));
|
||||
await expect(successIndicator.first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Security', () => {
|
||||
test('GET response should NOT expose the API token value', async ({ page }) => {
|
||||
let apiResponseBody: Array<Record<string, unknown>> | null = null;
|
||||
|
||||
let resolveRouteBody: (data: Array<Record<string, unknown>>) => void;
|
||||
const routeBodyPromise = new Promise<Array<Record<string, unknown>>>((resolve) => {
|
||||
resolveRouteBody = resolve;
|
||||
});
|
||||
|
||||
await test.step('Mock provider list with has_token flag', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
const body = [
|
||||
{
|
||||
id: 'po-sec-id',
|
||||
name: 'Pushover Secure',
|
||||
type: 'pushover',
|
||||
url: 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG',
|
||||
has_token: true,
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: false,
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
resolveRouteBody!(body);
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Navigate to trigger GET', async () => {
|
||||
await page.reload();
|
||||
apiResponseBody = await Promise.race([
|
||||
routeBodyPromise,
|
||||
new Promise<Array<Record<string, unknown>>>((_resolve, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timed out waiting for GET /api/v1/notifications/providers')),
|
||||
15000
|
||||
)
|
||||
),
|
||||
]);
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify API token is not in API response', async () => {
|
||||
expect(apiResponseBody).toBeTruthy();
|
||||
const provider = apiResponseBody![0];
|
||||
expect(provider.token).toBeUndefined();
|
||||
expect(provider.gotify_token).toBeUndefined();
|
||||
const responseStr = JSON.stringify(provider);
|
||||
expect(responseStr).not.toContain('azGDORePK8gMaC0QOYAMyEEuzJnyUi');
|
||||
});
|
||||
});
|
||||
|
||||
test('API token should not appear in the url field or any visible field', async ({ page }) => {
|
||||
await test.step('Mock provider with clean URL field', async () => {
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 'po-url-sec-id',
|
||||
name: 'Pushover URL Check',
|
||||
type: 'pushover',
|
||||
url: 'uQiRzpo4DXghDmr9QzzfQu27cmVRsG',
|
||||
has_token: true,
|
||||
enabled: true,
|
||||
},
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Reload and verify API token does not appear in provider row', async () => {
|
||||
await page.reload();
|
||||
await waitForLoadingComplete(page);
|
||||
await expect(page.getByText('Pushover URL Check')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const providerRow = page.getByTestId('provider-row-po-url-sec-id');
|
||||
const urlText = await providerRow.textContent();
|
||||
expect(urlText).not.toContain('azGDORePK8gMaC0QOYAMyEEuzJnyUi');
|
||||
expect(urlText).not.toContain('api.pushover.net');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Payload Contract', () => {
|
||||
test('POST body should include type=pushover, url field = user key, token field is write-only', async ({ page }) => {
|
||||
const providerName = generateProviderName('po-contract');
|
||||
let capturedPayload: Record<string, unknown> | null = null;
|
||||
let capturedGetResponse: Array<Record<string, unknown>> | null = null;
|
||||
|
||||
await test.step('Mock create and list endpoints', async () => {
|
||||
const createdProviders: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route('**/api/v1/notifications/providers', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const payload = (await request.postDataJSON()) as Record<string, unknown>;
|
||||
capturedPayload = payload;
|
||||
const { token, gotify_token, ...rest } = payload;
|
||||
const created: Record<string, unknown> = {
|
||||
id: 'po-contract-1',
|
||||
...rest,
|
||||
has_token: !!(token || gotify_token),
|
||||
};
|
||||
createdProviders.push(created);
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(created),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'GET') {
|
||||
capturedGetResponse = [...createdProviders];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createdProviders),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('Create a pushover provider via the UI', 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('pushover');
|
||||
await page.getByTestId('provider-name').fill(providerName);
|
||||
await page.getByTestId('provider-url').fill('uQiRzpo4DXghDmr9QzzfQu27cmVRsG');
|
||||
await page.getByTestId('provider-gotify-token').fill('azGDORePK8gMaC0QOYAMyEEuzJnyUi');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
/\/api\/v1\/notifications\/providers/.test(resp.url()) &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201
|
||||
),
|
||||
page.getByTestId('provider-save-btn').click(),
|
||||
]);
|
||||
});
|
||||
|
||||
await test.step('Verify POST payload: type=pushover, url=user key, token=api token', async () => {
|
||||
expect(capturedPayload).toBeTruthy();
|
||||
expect(capturedPayload?.type).toBe('pushover');
|
||||
expect(capturedPayload?.url).toBe('uQiRzpo4DXghDmr9QzzfQu27cmVRsG');
|
||||
expect(capturedPayload?.token).toBe('azGDORePK8gMaC0QOYAMyEEuzJnyUi');
|
||||
expect(capturedPayload?.gotify_token).toBeUndefined();
|
||||
});
|
||||
|
||||
await test.step('Verify GET response: has_token=true, token value absent', async () => {
|
||||
await expect(page.getByText(providerName).first()).toBeVisible({ timeout: 10000 });
|
||||
expect(capturedGetResponse).toBeTruthy();
|
||||
const provider = capturedGetResponse![0];
|
||||
expect(provider.has_token).toBe(true);
|
||||
expect(provider.token).toBeUndefined();
|
||||
expect(provider.gotify_token).toBeUndefined();
|
||||
const responseStr = JSON.stringify(provider);
|
||||
expect(responseStr).not.toContain('azGDORePK8gMaC0QOYAMyEEuzJnyUi');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user