feat: Enhance Notifications feature with accessibility improvements and test remediation
- Added aria-label attributes to buttons in Notifications component for better accessibility. - Updated Notifications tests to use new button interactions and ensure proper functionality. - Refactored notifications payload tests to mock API responses and validate payload transformations. - Improved error handling and feedback in notification provider tests. - Adjusted Telegram notification provider tests to streamline edit interactions.
This commit is contained in:
686
docs/plans/telegram_implementation_spec.md
Normal file
686
docs/plans/telegram_implementation_spec.md
Normal file
@@ -0,0 +1,686 @@
|
||||
# Telegram Notification Provider — Implementation Plan
|
||||
|
||||
**Date:** 2026-07-10
|
||||
**Author:** Planning Agent
|
||||
**Confidence Score:** 92% (High — existing patterns well-established, Telegram Bot API straightforward)
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
### Objective
|
||||
|
||||
Add Telegram as a first-class notification provider in Charon, following the established architecture used by Discord, Gotify, Email, and generic Webhook providers.
|
||||
|
||||
### Goals
|
||||
|
||||
- Users can configure a Telegram bot token and chat ID to receive notifications via Telegram
|
||||
- All existing notification event types (proxy hosts, certs, uptime, security events) work with Telegram
|
||||
- JSON template engine (minimal/detailed/custom) works with Telegram
|
||||
- Feature flag allows enabling/disabling Telegram dispatch independently
|
||||
- Token is treated as a secret (write-only, never exposed in API responses)
|
||||
- Full test coverage: Go unit tests, Vitest frontend tests, Playwright E2E tests
|
||||
|
||||
### Telegram Bot API Overview
|
||||
|
||||
Telegram bots send messages via:
|
||||
|
||||
```
|
||||
POST https://api.telegram.org/bot<BOT_TOKEN>/sendMessage
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"chat_id": "<CHAT_ID>",
|
||||
"text": "Hello world",
|
||||
"parse_mode": "HTML" // optional: "HTML" or "MarkdownV2"
|
||||
}
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
- **Token storage:** The bot token is stored in `NotificationProvider.Token` (`json:"-"`, encrypted at rest) — never in the URL field. This mirrors the Gotify pattern where secrets are separated from endpoints.
|
||||
- **URL field:** Stores only the `chat_id` (e.g., `987654321`). At dispatch time, the full API URL is constructed dynamically: `https://api.telegram.org/bot` + decryptedToken + `/sendMessage`. The `chat_id` is passed in the POST body alongside the message text. This prevents token leakage via API responses since URL is `json:"url"`.
|
||||
- **SSRF mitigation:** Before dispatching, validate that the constructed URL hostname is exactly `api.telegram.org`. This prevents SSRF if stored data is tampered with.
|
||||
- **Dispatch path:** Uses `sendJSONPayload` → `httpWrapper.Send()` (same as Gotify), since both are token-based JSON POST providers
|
||||
- **No schema migration needed:** The existing `NotificationProvider` model accommodates Telegram without changes
|
||||
|
||||
> **Supervisor Review Note:** The original design embedded the bot token in the URL field (`https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<CHAT_ID>`). This was rejected because the URL field is `json:"url"` — exposed in every API response. The token MUST only reside in the `Token` field (`json:"-"`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Research Findings
|
||||
|
||||
### Existing Architecture
|
||||
|
||||
The notification system follows a provider-based architecture:
|
||||
|
||||
| Layer | File | Role |
|
||||
|-------|------|------|
|
||||
| Feature flags | `backend/internal/notifications/feature_flags.go` | Flag constants (`FlagXxxServiceEnabled`) |
|
||||
| Feature flag handler | `backend/internal/api/handlers/feature_flags_handler.go` | DB-backed flags with defaults |
|
||||
| Router | `backend/internal/notifications/router.go` | `ShouldUseNotify()` per-type dispatch |
|
||||
| Service | `backend/internal/services/notification_service.go` | Core dispatch: `isSupportedNotificationProviderType()`, `isDispatchEnabled()`, `supportsJSONTemplates()`, `sendJSONPayload()`, `TestProvider()` |
|
||||
| Handlers | `backend/internal/api/handlers/notification_provider_handler.go` | CRUD + type validation + token preservation |
|
||||
| Model | `backend/internal/models/notification_provider.go` | GORM model with Token (json:"-"), HasToken |
|
||||
| Frontend API | `frontend/src/api/notifications.ts` | `SUPPORTED_NOTIFICATION_PROVIDER_TYPES`, sanitization |
|
||||
| Frontend UI | `frontend/src/pages/Notifications.tsx` | Provider form with conditional fields per type |
|
||||
| i18n | `frontend/src/locales/en/translation.json` | Label strings |
|
||||
| E2E fixtures | `tests/fixtures/notifications.ts` | `telegramProvider` **already defined** |
|
||||
|
||||
### Existing Provider Addition Points (Switch Statements / Type Checks)
|
||||
|
||||
Every location that checks provider types is listed below — all require a `"telegram"` case:
|
||||
|
||||
| # | File | Function/Line | Current Logic |
|
||||
|---|------|---------------|---------------|
|
||||
| 1 | `feature_flags.go` | Constants | Missing `FlagTelegramServiceEnabled` |
|
||||
| 2 | `feature_flags_handler.go` | `defaultFlags` + `defaultFlagValues` | Missing telegram entry |
|
||||
| 3 | `router.go` | `ShouldUseNotify()` switch | Missing `case "telegram"` |
|
||||
| 4 | `notification_service.go` | `isSupportedNotificationProviderType()` | `case "discord", "email", "gotify", "webhook"` |
|
||||
| 5 | `notification_service.go` | `isDispatchEnabled()` | switch with per-type flag checks |
|
||||
| 6 | `notification_service.go` | `supportsJSONTemplates()` | `case "webhook", "discord", "gotify", "slack", "generic"` |
|
||||
| 7 | `notification_service.go` | `sendJSONPayload()` — service-specific validation | Missing `case "telegram"` for payload validation |
|
||||
| 8 | `notification_service.go` | `sendJSONPayload()` — dispatch branch | Gotify/webhook use `httpWrapper.Send()`; others use `ValidateExternalURL` + `SafeHTTPClient` |
|
||||
| 9 | `notification_provider_handler.go` | `Create()` type guard | `providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email"` |
|
||||
| 10 | `notification_provider_handler.go` | `Update()` type guard | Same pattern as Create |
|
||||
| 11 | `notification_provider_handler.go` | `Update()` token preservation | `if providerType == "gotify" && strings.TrimSpace(req.Token) == ""` |
|
||||
| 12 | `notifications.ts` | `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` | `['discord', 'gotify', 'webhook', 'email']` |
|
||||
| 13 | `notifications.ts` | `sanitizeProviderForWriteAction()` | Token handling only for `type !== 'gotify'` |
|
||||
| 14 | `Notifications.tsx` | Type `<select>` options | discord/gotify/webhook/email |
|
||||
| 15 | `Notifications.tsx` | `normalizeProviderPayloadForSubmit()` | Token mapping only for `type === 'gotify'` |
|
||||
| 16 | `Notifications.tsx` | Conditional form fields | `isGotify` shows token input |
|
||||
|
||||
### Test Files Requiring Updates
|
||||
|
||||
| File | Current Behavior | Required Change |
|
||||
|------|-----------------|-----------------|
|
||||
| `notification_service_test.go` (~L1819) | `TestTestProvider_NotifyOnlyRejectsUnsupportedProvider` tests `"telegram"` as **unsupported** | Change: telegram is now supported |
|
||||
| `notification_service_json_test.go` | Discord/Slack/Gotify/Webhook JSON tests | Add telegram payload validation tests |
|
||||
| `notification_provider_handler_test.go` | CRUD tests with supported types | Add telegram to supported type lists |
|
||||
| `enhanced_security_notification_service_test.go` (~L139) | `Type: "telegram"` marked `// Should be filtered` | Change: telegram is now valid |
|
||||
| `frontend/src/api/notifications.test.ts` | Rejects `"telegram"` as unsupported | Accept telegram, add CRUD tests |
|
||||
| `frontend/src/api/__tests__/notifications.test.ts` | Same rejection | Same fix |
|
||||
| `tests/settings/notifications.spec.ts` | CRUD E2E for discord/gotify/webhook/email | Add telegram scenarios |
|
||||
|
||||
### E2E Fixture Already Defined
|
||||
|
||||
```typescript
|
||||
// tests/fixtures/notifications.ts
|
||||
// NOTE: Fixture must be updated — URL should contain only the chat_id, token goes in the token field
|
||||
export const telegramProvider: NotificationProviderConfig = {
|
||||
name: generateProviderName('telegram'),
|
||||
type: 'telegram',
|
||||
url: '987654321', // chat_id only — bot token is stored in the Token field
|
||||
token: 'bot123456789:ABCdefGHIjklMNOpqrSTUvwxYZ', // stored encrypted, never in API responses
|
||||
enabled: true,
|
||||
notify_proxy_hosts: true,
|
||||
notify_certs: true,
|
||||
notify_uptime: true,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Technical Specifications
|
||||
|
||||
### 3.1 Backend — Feature Flags
|
||||
|
||||
**File:** `backend/internal/notifications/feature_flags.go`
|
||||
|
||||
Add constant:
|
||||
|
||||
```go
|
||||
FlagTelegramServiceEnabled = "feature.notifications.service.telegram.enabled"
|
||||
```
|
||||
|
||||
**File:** `backend/internal/api/handlers/feature_flags_handler.go`
|
||||
|
||||
Add to `defaultFlags` slice:
|
||||
|
||||
```go
|
||||
notifications.FlagTelegramServiceEnabled,
|
||||
```
|
||||
|
||||
Add to `defaultFlagValues` map:
|
||||
|
||||
```go
|
||||
notifications.FlagTelegramServiceEnabled: true,
|
||||
```
|
||||
|
||||
> **Note:** Telegram is **enabled by default** once the provider is toggled on in the UI, matching Gotify/Webhook behavior. The feature flag exists as an admin-level kill switch, not a setup gate.
|
||||
|
||||
### 3.2 Backend — Router
|
||||
|
||||
**File:** `backend/internal/notifications/router.go`
|
||||
|
||||
Add to `ShouldUseNotify()` switch:
|
||||
|
||||
```go
|
||||
case "telegram":
|
||||
return flags[FlagTelegramServiceEnabled]
|
||||
```
|
||||
|
||||
### 3.3 Backend — Notification Service
|
||||
|
||||
**File:** `backend/internal/services/notification_service.go`
|
||||
|
||||
#### `isSupportedNotificationProviderType()`
|
||||
|
||||
```go
|
||||
case "discord", "email", "gotify", "webhook", "telegram":
|
||||
return true
|
||||
```
|
||||
|
||||
#### `isDispatchEnabled()`
|
||||
|
||||
```go
|
||||
case "telegram":
|
||||
return s.getFeatureFlagValue(notifications.FlagTelegramServiceEnabled, true)
|
||||
```
|
||||
|
||||
Both `defaultFlagValues` (initial DB seed) and the `isDispatchEnabled()` fallback are `true` — Telegram is enabled by default once the provider is created in the UI. This matches Gotify/Webhook behavior (enabled-by-default, admin kill-switch via feature flag).
|
||||
|
||||
#### `supportsJSONTemplates()`
|
||||
|
||||
```go
|
||||
case "webhook", "discord", "gotify", "slack", "generic", "telegram":
|
||||
return true
|
||||
```
|
||||
|
||||
#### `sendJSONPayload()` — Service-Specific Validation
|
||||
|
||||
Add after the `case "gotify":` block:
|
||||
|
||||
```go
|
||||
case "telegram":
|
||||
// Telegram requires 'text' field for the message body
|
||||
if _, hasText := jsonPayload["text"]; !hasText {
|
||||
// Auto-map 'message' to 'text' if present (template compatibility)
|
||||
if messageValue, hasMessage := jsonPayload["message"]; hasMessage {
|
||||
jsonPayload["text"] = messageValue
|
||||
normalizedBody, marshalErr := json.Marshal(jsonPayload)
|
||||
if marshalErr != nil {
|
||||
return fmt.Errorf("failed to normalize telegram payload: %w", marshalErr)
|
||||
}
|
||||
body.Reset()
|
||||
if _, writeErr := body.Write(normalizedBody); writeErr != nil {
|
||||
return fmt.Errorf("failed to write normalized telegram payload: %w", writeErr)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("telegram payload requires 'text' field")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This auto-mapping mirrors the Discord pattern (`message` → `content`) so that the built-in `minimal` and `detailed` templates (which use `"message"` as a field) work out of the box with Telegram.
|
||||
|
||||
#### `sendJSONPayload()` — Dispatch Branch
|
||||
|
||||
Add `"telegram"` to the `httpWrapper.Send()` dispatch branch alongside gotify/webhook:
|
||||
|
||||
```go
|
||||
if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" {
|
||||
```
|
||||
|
||||
For telegram, the dispatch URL must be **constructed at send time** from the stored token and chat_id:
|
||||
|
||||
```go
|
||||
case "telegram":
|
||||
// Construct the API URL dynamically — token is NEVER stored in the URL field
|
||||
decryptedToken := provider.Token // already decrypted by service layer
|
||||
dispatchURL = "https://api.telegram.org/bot" + decryptedToken + "/sendMessage"
|
||||
|
||||
// SSRF mitigation: validate hostname before dispatch
|
||||
parsedURL, err := url.Parse(dispatchURL)
|
||||
if err != nil || parsedURL.Hostname() != "api.telegram.org" {
|
||||
return fmt.Errorf("telegram dispatch URL validation failed: invalid hostname")
|
||||
}
|
||||
|
||||
// Inject chat_id into the JSON payload body (URL field stores the chat_id)
|
||||
jsonPayload["chat_id"] = provider.URL
|
||||
// Re-marshal the payload with chat_id included
|
||||
updatedBody, marshalErr := json.Marshal(jsonPayload)
|
||||
if marshalErr != nil {
|
||||
return fmt.Errorf("failed to marshal telegram payload with chat_id: %w", marshalErr)
|
||||
}
|
||||
body.Reset()
|
||||
body.Write(updatedBody)
|
||||
```
|
||||
|
||||
The `X-Gotify-Key` header is only set when `providerType == "gotify"` — no header changes needed for telegram.
|
||||
|
||||
#### `TestProvider()` — Telegram-Specific Error Message
|
||||
|
||||
When testing a Telegram provider and the API returns HTTP 401 or 403, return a specific error message:
|
||||
|
||||
```go
|
||||
case "telegram":
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("provider rejected authentication. Verify your Telegram Bot Token")
|
||||
}
|
||||
```
|
||||
|
||||
This gives users actionable guidance instead of a generic HTTP status error.
|
||||
|
||||
### 3.4 Backend — Handler Layer
|
||||
|
||||
**File:** `backend/internal/api/handlers/notification_provider_handler.go`
|
||||
|
||||
#### `Create()` Type Guard
|
||||
|
||||
```go
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" {
|
||||
```
|
||||
|
||||
#### `Update()` Type Guard
|
||||
|
||||
Same change as Create:
|
||||
|
||||
```go
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" {
|
||||
```
|
||||
|
||||
#### `Update()` Token Preservation
|
||||
|
||||
Telegram bot tokens should be preserved on update when the user omits them (same UX as Gotify):
|
||||
|
||||
```go
|
||||
if (providerType == "gotify" || providerType == "telegram") && strings.TrimSpace(req.Token) == "" {
|
||||
req.Token = existing.Token
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Backend — Model (No Changes)
|
||||
|
||||
The `NotificationProvider` model already has:
|
||||
|
||||
- `Token string` with `json:"-"` (write-only, never exposed)
|
||||
- `HasToken bool` with `gorm:"-"` (computed field for frontend)
|
||||
- `URL string` for the endpoint
|
||||
- `ServiceConfig string` for extra JSON config (available for `parse_mode` if needed)
|
||||
|
||||
No schema migration is required.
|
||||
|
||||
### 3.6 Frontend — API Client
|
||||
|
||||
**File:** `frontend/src/api/notifications.ts`
|
||||
|
||||
#### Supported Types Array
|
||||
|
||||
```typescript
|
||||
export const SUPPORTED_NOTIFICATION_PROVIDER_TYPES = ['discord', 'gotify', 'webhook', 'email', 'telegram'] as const;
|
||||
```
|
||||
|
||||
#### `sanitizeProviderForWriteAction()`
|
||||
|
||||
**Minimal diff only.** Change only the type guard condition from:
|
||||
|
||||
```typescript
|
||||
if (type !== 'gotify') {
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
if (type !== 'gotify' && type !== 'telegram') {
|
||||
```
|
||||
|
||||
The surrounding normalization logic (token stripping, payload return) MUST remain untouched. No other lines in this function change.
|
||||
|
||||
#### `sanitizeProviderForReadLikeAction()`
|
||||
|
||||
No changes — already calls `sanitizeProviderForWriteAction()` then strips token.
|
||||
|
||||
### 3.7 Frontend — Notifications Page
|
||||
|
||||
**File:** `frontend/src/pages/Notifications.tsx`
|
||||
|
||||
#### Type Select Options
|
||||
|
||||
Add after the email option:
|
||||
|
||||
```tsx
|
||||
<option value="telegram">Telegram</option>
|
||||
```
|
||||
|
||||
#### Computed Flags
|
||||
|
||||
```typescript
|
||||
const isTelegram = type === 'telegram';
|
||||
```
|
||||
|
||||
#### `normalizeProviderPayloadForSubmit()`
|
||||
|
||||
Add telegram branch alongside gotify:
|
||||
|
||||
```typescript
|
||||
if (type === 'gotify' || type === 'telegram') {
|
||||
const normalizedToken = typeof payload.gotify_token === 'string' ? payload.gotify_token.trim() : '';
|
||||
if (normalizedToken.length > 0) {
|
||||
payload.token = normalizedToken;
|
||||
} else {
|
||||
delete payload.token;
|
||||
}
|
||||
} else {
|
||||
delete payload.token;
|
||||
}
|
||||
```
|
||||
|
||||
Note: Reuses the `gotify_token` form field for both Gotify and Telegram since both need a token input. This minimizes UI changes. The field label changes based on provider type.
|
||||
|
||||
#### Token Input Field
|
||||
|
||||
Expand the conditional from `{isGotify && (` to `{(isGotify || isTelegram) && (`:
|
||||
|
||||
```tsx
|
||||
{(isGotify || isTelegram) && (
|
||||
<div>
|
||||
<label htmlFor="provider-gotify-token" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{isTelegram ? t('notificationProviders.telegramBotToken') : t('notificationProviders.gotifyToken')}
|
||||
</label>
|
||||
<input
|
||||
id="provider-gotify-token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
{...register('gotify_token')}
|
||||
data-testid="provider-gotify-token"
|
||||
placeholder={initialData?.has_token
|
||||
? t('notificationProviders.gotifyTokenKeepPlaceholder')
|
||||
: 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}
|
||||
/>
|
||||
{initialData?.has_token && (
|
||||
<p id="gotify-token-stored-hint" data-testid="gotify-token-stored-indicator" className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
{t('notificationProviders.gotifyTokenStored')}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">{t('notificationProviders.gotifyTokenWriteOnlyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
#### URL Field Placeholder
|
||||
|
||||
For Telegram, the URL field stores the chat_id (not a full URL). Update the placeholder and label accordingly:
|
||||
|
||||
```typescript
|
||||
placeholder={
|
||||
isEmail ? 'user@example.com, admin@example.com'
|
||||
: type === 'discord' ? 'https://discord.com/api/webhooks/...'
|
||||
: type === 'gotify' ? 'https://gotify.example.com/message'
|
||||
: isTelegram ? '987654321'
|
||||
: 'https://example.com/webhook'
|
||||
}
|
||||
```
|
||||
|
||||
Update the label for the URL field when type is telegram:
|
||||
|
||||
```typescript
|
||||
label={isTelegram ? t('notificationProviders.telegramChatId') : t('notificationProviders.url')}
|
||||
```
|
||||
|
||||
#### Clear Token on Type Change
|
||||
|
||||
Update the existing `useEffect` that clears `gotify_token`:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (type !== 'gotify' && type !== 'telegram') {
|
||||
setValue('gotify_token', '', { shouldDirty: false, shouldTouch: false });
|
||||
}
|
||||
}, [type, setValue]);
|
||||
```
|
||||
|
||||
### 3.8 Frontend — i18n Strings
|
||||
|
||||
**File:** `frontend/src/locales/en/translation.json`
|
||||
|
||||
Add to the `notificationProviders` section:
|
||||
|
||||
```json
|
||||
"telegram": "Telegram",
|
||||
"telegramBotToken": "Bot Token",
|
||||
"telegramBotTokenPlaceholder": "Enter your Telegram Bot Token",
|
||||
"telegramChatId": "Chat ID",
|
||||
"telegramChatIdPlaceholder": "987654321",
|
||||
"telegramChatIdHelp": "Your Telegram chat, group, or channel ID. The bot token is stored securely and separately."
|
||||
```
|
||||
|
||||
### 3.9 API Contract (No Changes)
|
||||
|
||||
The existing REST endpoints remain unchanged:
|
||||
|
||||
| Method | Endpoint | Notes |
|
||||
|--------|----------|-------|
|
||||
| `GET` | `/api/notification-providers` | Returns all providers (token stripped) |
|
||||
| `POST` | `/api/notification-providers` | Create — now accepts `type: "telegram"` |
|
||||
| `PUT` | `/api/notification-providers/:id` | Update — token preserved if omitted |
|
||||
| `DELETE` | `/api/notification-providers/:id` | Delete — no type-specific logic |
|
||||
| `POST` | `/api/notification-providers/test` | Test — routes through `sendJSONPayload` |
|
||||
|
||||
Request/response schemas are unchanged. The `type` field now accepts `"telegram"` in addition to existing values.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Phase 1: Playwright E2E Tests (Test-First)
|
||||
|
||||
**Rationale:** Per project conventions — write feature behaviour tests first.
|
||||
|
||||
**New file:** `tests/settings/telegram-notification-provider.spec.ts`
|
||||
|
||||
Modeled after `tests/settings/email-notification-provider.spec.ts`.
|
||||
|
||||
Test scenarios:
|
||||
1. Create a Telegram provider (name, chat_id in URL field, bot token in token field, enable events)
|
||||
2. Verify provider appears in the list
|
||||
3. Edit the Telegram provider (change name, verify token preservation)
|
||||
4. Test the Telegram provider (mock API returns 200)
|
||||
5. Delete the Telegram provider
|
||||
6. **Negative security test:** Verify `GET /api/notification-providers` does NOT expose the bot token in any response field
|
||||
7. **Negative security test:** Verify bot token is NOT present in the URL field of the API response
|
||||
|
||||
**Update file:** `tests/settings/notifications-payload.spec.ts`
|
||||
|
||||
Add telegram to the payload matrix test scenarios.
|
||||
|
||||
**E2E fixtures:** Update `telegramProvider` in `tests/fixtures/notifications.ts` — URL must contain only `chat_id`, token goes in the `token` field (see Research Findings section for updated fixture).
|
||||
|
||||
### Phase 2: Backend Implementation
|
||||
|
||||
**2A — Feature Flags (3 files)**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/internal/notifications/feature_flags.go` | Add `FlagTelegramServiceEnabled` constant |
|
||||
| `backend/internal/api/handlers/feature_flags_handler.go` | Add to `defaultFlags` + `defaultFlagValues` |
|
||||
| `backend/internal/notifications/router.go` | Add `case "telegram"` to `ShouldUseNotify()` |
|
||||
|
||||
**2B — Service Layer (1 file, 4 function changes)**
|
||||
|
||||
| File | Function | Change |
|
||||
|------|----------|--------|
|
||||
| `notification_service.go` | `isSupportedNotificationProviderType()` | Add `"telegram"` to case |
|
||||
| `notification_service.go` | `isDispatchEnabled()` | Add `case "telegram"` with flag check |
|
||||
| `notification_service.go` | `supportsJSONTemplates()` | Add `"telegram"` to case |
|
||||
| `notification_service.go` | `sendJSONPayload()` | Add telegram validation + dispatch branch |
|
||||
|
||||
**2C — Handler Layer (1 file, 3 locations)**
|
||||
|
||||
| File | Location | Change |
|
||||
|------|----------|--------|
|
||||
| `notification_provider_handler.go` | `Create()` type guard | Add `&& providerType != "telegram"` |
|
||||
| `notification_provider_handler.go` | `Update()` type guard | Same |
|
||||
| `notification_provider_handler.go` | `Update()` token preservation | Add `|| providerType == "telegram"` |
|
||||
|
||||
### Phase 3: Frontend Implementation
|
||||
|
||||
**3A — API Client (1 file)**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/api/notifications.ts` | Add `'telegram'` to `SUPPORTED_NOTIFICATION_PROVIDER_TYPES`, update token sanitization logic |
|
||||
|
||||
**3B — Notifications Page (1 file)**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/pages/Notifications.tsx` | Add telegram to type select, token field conditional, URL placeholder, `normalizeProviderPayloadForSubmit()`, type-change useEffect |
|
||||
|
||||
**3C — Localization (1 file)**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/locales/en/translation.json` | Add telegram-specific label strings |
|
||||
|
||||
### Phase 4: Backend Tests
|
||||
|
||||
| Test File | Changes |
|
||||
|-----------|---------|
|
||||
| `notification_service_test.go` | Update "rejects unsupported provider" test (remove telegram from unsupported list). Add telegram dispatch/integration tests. |
|
||||
| `notification_service_json_test.go` | Add `TestSendJSONPayload_Telegram_*` tests: valid payload, missing text with message auto-map, missing both text and message, dispatch via httpWrapper, **SSRF hostname validation**, **401/403 error message** |
|
||||
| `notification_provider_handler_test.go` | Add telegram to Create/Update happy path tests, token preservation test. **Add negative test: verify GET response does not contain bot token in URL field or response body** |
|
||||
| `enhanced_security_notification_service_test.go` | Change telegram from "filtered" to "valid provider" in security dispatch tests |
|
||||
| Router test (if exists) | Add telegram to `ShouldUseNotify()` tests |
|
||||
|
||||
### Phase 5: Frontend Tests
|
||||
|
||||
| Test File | Changes |
|
||||
|-----------|---------|
|
||||
| `frontend/src/api/notifications.test.ts` | Remove telegram rejection test, add telegram CRUD sanitization tests |
|
||||
| `frontend/src/api/__tests__/notifications.test.ts` | Same changes (duplicate test location) |
|
||||
| `frontend/src/pages/Notifications.test.tsx` | Add telegram form rendering tests (token field visibility, placeholder text) |
|
||||
|
||||
### Phase 6: Integration, Documentation & Deployment
|
||||
|
||||
- Verify E2E tests pass with Docker container
|
||||
- Update `docs/features.md` with Telegram provider mention
|
||||
- No `ARCHITECTURE.md` changes needed (same provider pattern)
|
||||
- No database migration needed
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
### EARS Requirements
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| T-01 | WHEN a user creates a notification provider with type "telegram", THE SYSTEM SHALL accept the provider and store it in the database |
|
||||
| T-02 | WHEN a user provides a bot token for a Telegram provider, THE SYSTEM SHALL store it securely and never expose it in API responses |
|
||||
| T-03 | WHEN a Telegram provider is enabled and a notification event fires, THE SYSTEM SHALL construct the Telegram API URL dynamically from the stored token (`https://api.telegram.org/bot` + token + `/sendMessage`), inject `chat_id` from the URL field into the POST body, and send the rendered template payload |
|
||||
| T-04 | WHEN the rendered JSON payload contains a "message" field but not a "text" field, THE SYSTEM SHALL auto-map "message" to "text" for Telegram compatibility |
|
||||
| T-05 | WHEN the Telegram feature flag is disabled, THE SYSTEM SHALL skip dispatch for all Telegram providers |
|
||||
| T-06 | WHEN a user updates a Telegram provider without providing a token, THE SYSTEM SHALL preserve the existing stored token |
|
||||
| T-07 | WHEN a user tests a Telegram provider, THE SYSTEM SHALL send a test notification through the standard sendJSONPayload path |
|
||||
| T-08 | WHEN the frontend renders the provider form with type "telegram", THE SYSTEM SHALL display a bot token input field and a chat_id input field (with appropriate placeholder) |
|
||||
| T-09 | WHEN dispatching a Telegram notification, THE SYSTEM SHALL validate that the constructed URL hostname is exactly `api.telegram.org` before sending (SSRF mitigation) |
|
||||
| T-10 | WHEN a Telegram test request receives HTTP 401 or 403, THE SYSTEM SHALL return the error message "Provider rejected authentication. Verify your Telegram Bot Token" |
|
||||
| T-11 | WHEN the API returns notification providers via GET, THE SYSTEM SHALL NOT include the bot token in the URL field or any other exposed response field |
|
||||
|
||||
### Definition of Done
|
||||
|
||||
- [ ] All 16 code touchpoints updated (see section 2 table)
|
||||
- [ ] E2E Playwright tests pass for Telegram CRUD + test send
|
||||
- [ ] Backend unit tests cover: type registration, dispatch routing, payload validation (text field), token preservation, feature flag gating
|
||||
- [ ] Frontend unit tests cover: type array acceptance, sanitization, form rendering
|
||||
- [ ] `go test ./...` passes
|
||||
- [ ] `npm test` passes
|
||||
- [ ] `npx playwright test --project=firefox` passes
|
||||
- [ ] `make lint-fast` passes (staticcheck)
|
||||
- [ ] Coverage threshold maintained (85%+)
|
||||
- [ ] GORM security scan passes (no model changes, but verify)
|
||||
- [ ] Token never appears in API responses, logs, or frontend state
|
||||
- [ ] Negative security tests pass (bot token not in GET response body or URL field)
|
||||
- [ ] SSRF hostname validation test passes (only `api.telegram.org` allowed)
|
||||
- [ ] Telegram 401/403 returns specific auth error message
|
||||
|
||||
---
|
||||
|
||||
## 6. Commit Slicing Strategy
|
||||
|
||||
### Decision: 2 PRs
|
||||
|
||||
**Trigger reasons:** Changes span backend + frontend + E2E tests with independent functionality per layer. Splitting improves review quality and rollback safety.
|
||||
|
||||
### PR-1: Backend — Telegram Provider Support
|
||||
|
||||
**Scope:** Feature flags, service layer, handler layer, all Go unit tests
|
||||
|
||||
**Files changed:**
|
||||
- `backend/internal/notifications/feature_flags.go`
|
||||
- `backend/internal/api/handlers/feature_flags_handler.go`
|
||||
- `backend/internal/notifications/router.go`
|
||||
- `backend/internal/services/notification_service.go`
|
||||
- `backend/internal/api/handlers/notification_provider_handler.go`
|
||||
- `backend/internal/services/notification_service_test.go`
|
||||
- `backend/internal/services/notification_service_json_test.go`
|
||||
- `backend/internal/api/handlers/notification_provider_handler_test.go`
|
||||
- `backend/internal/services/enhanced_security_notification_service_test.go`
|
||||
|
||||
**Dependencies:** None (self-contained backend change)
|
||||
|
||||
**Validation gates:**
|
||||
- `go test ./...` passes
|
||||
- `make lint-fast` passes
|
||||
- Coverage ≥ 85%
|
||||
- GORM security scan passes
|
||||
|
||||
**Rollback:** Revert PR — no DB migration to undo.
|
||||
|
||||
### PR-2: Frontend + E2E — Telegram Provider UI
|
||||
|
||||
**Scope:** Frontend API client, Notifications page, i18n strings, frontend unit tests, Playwright E2E tests
|
||||
|
||||
**Files changed:**
|
||||
- `frontend/src/api/notifications.ts`
|
||||
- `frontend/src/pages/Notifications.tsx`
|
||||
- `frontend/src/locales/en/translation.json`
|
||||
- `frontend/src/api/notifications.test.ts`
|
||||
- `frontend/src/api/__tests__/notifications.test.ts`
|
||||
- `frontend/src/pages/Notifications.test.tsx`
|
||||
- `tests/settings/telegram-notification-provider.spec.ts` (new)
|
||||
- `tests/settings/notifications-payload.spec.ts`
|
||||
|
||||
**Dependencies:** PR-1 must be merged first (backend must accept `type: "telegram"`)
|
||||
|
||||
**Validation gates:**
|
||||
- `npm test` passes
|
||||
- `npm run type-check` passes
|
||||
- `npx playwright test --project=firefox` passes
|
||||
- Coverage ≥ 85%
|
||||
|
||||
**Rollback:** Revert PR — frontend-only, no cascading effects.
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| Telegram API rate limiting | Low | Medium | Use existing retry/timeout patterns from httpWrapper |
|
||||
| Bot token exposure in responses/logs | Low | Critical | Token stored ONLY in `Token` field (`json:"-"`), never in URL field. URL field contains only `chat_id`. Negative security tests verify this invariant. |
|
||||
| Template auto-mapping edge cases | Low | Low | Test with all three template types (minimal, detailed, custom) |
|
||||
| URL validation rejects chat_id format | Low | Low | URL field now stores a chat_id string (not a full URL). Validation may need adjustment to accept non-URL values for telegram type. |
|
||||
| SSRF via tampered stored data | Low | High | Dispatch-time validation ensures hostname is exactly `api.telegram.org`. Dedicated test covers this. |
|
||||
| E2E test flakiness with mocked API | Low | Low | Existing route-mocking patterns are stable |
|
||||
|
||||
---
|
||||
|
||||
## 8. Complexity Estimates
|
||||
|
||||
| Component | Estimate | Notes |
|
||||
|-----------|----------|-------|
|
||||
| Backend feature flags | S | 3 files, ~5 lines each |
|
||||
| Backend service layer | M | 4 function changes + telegram validation block |
|
||||
| Backend handler layer | S | 3 string-level changes |
|
||||
| Frontend API client | S | 2 lines + sanitization tweak |
|
||||
| Frontend UI | M | Template conditional, placeholder, useEffect updates |
|
||||
| Frontend i18n | S | 4 strings |
|
||||
| Backend tests | L | Multiple test files, new test functions, update existing assertions |
|
||||
| Frontend tests | M | Update rejection tests, add rendering tests |
|
||||
| E2E tests | M | New spec file modeled on existing email spec |
|
||||
| **Total** | **M-L** | ~2-3 days of focused implementation |
|
||||
Reference in New Issue
Block a user