feat: replace YAML notification channel editor with form-based UI

Notification channels are now managed through a proper web form instead
of a raw YAML textarea. Any authenticated user can create channels; private
channels (owner-scoped) are hidden from other users. The user profile
channel selector becomes a tag/chip picker with a "My Channels" CRUD section.

- settings.py: add CHANNEL_TYPE_SCHEMAS for all 6 notifier types; channel
  section switches to section_mode="channels"; cards include owner/private/min_level
- configio.py: add apply_channel() and delete_channel() for per-entry CRUD
- notify.py: strip owner/private metadata before dispatching to drivers
- http.py: add GET/POST /api/0/notification_channels, PUT/DELETE /{name},
  GET /api/0/notification_channel_types; visibility helper filters private
  channels per user; PUT /api/0/users/me validates against visible channels
- settings.html: card grid with edit/delete per channel; add/edit modal
  with type dropdown and dynamically rendered type-specific fields
- profile.html: chip picker replaces checkbox list; My Channels section
  for creating/editing/deleting user-owned channels
- tests: update test_settings_sections, test_http_users_me; add
  test_notification_channels_api (16 new tests, 46 total passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-05-11 07:34:26 -04:00
parent a7a45bf8c3
commit 500d256d76
9 changed files with 1134 additions and 36 deletions
+13
View File
@@ -93,6 +93,19 @@ def apply_structured_section(data, section: str, values: dict) -> None:
raise ValueError(f"Unknown structured section: {section!r}")
def apply_channel(data, name: str, channel_cfg: dict) -> None:
"""Insert or replace a single notification channel entry, preserving others."""
if not data.get("notification_channels"):
data["notification_channels"] = {}
data["notification_channels"][name] = channel_cfg
def delete_channel(data, name: str) -> None:
"""Remove a notification channel by name. No-op if not found."""
nc = data.get("notification_channels") or {}
nc.pop(name, None)
def apply_yaml_section(data, section: str, yaml_text: str) -> None:
"""Replace the named logical section by parsing yaml_text."""
parsed = _make_yaml().load(yaml_text)
+248 -2
View File
@@ -956,7 +956,23 @@ async def start(
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")})
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
# Build visible channels list for chip picker and My Channels management.
visible_channels = _visible_channels_for_user(current_user) if current_user else {}
all_channels = sorted(
[
{
"name": name,
"type": cfg.get("type", ""),
"owner": cfg.get("owner"),
"private": bool(cfg.get("private", False)),
}
for name, cfg in visible_channels.items()
if isinstance(cfg, dict)
],
key=lambda c: c["name"],
)
# Keep all_channel_names for backwards-compat with any template references.
all_channel_names = [c["name"] for c in all_channels]
tmpl = env.get_template("profile.html")
body = tmpl.render(
@@ -967,6 +983,7 @@ async def start(
managed_hosts=managed,
monitored_hosts=monitored,
notification_channels=notif_channels,
all_channels=all_channels,
all_channel_names=all_channel_names,
active_page="profile",
)
@@ -1255,6 +1272,226 @@ async def start(
return web.json_response({"ok": True})
# -------------------------------------------------------------------------
# Notification channel helpers
# -------------------------------------------------------------------------
def _visible_channels_for_user(user):
"""Return {name: cfg} of channels visible to user (public + own private)."""
all_channels = config.get("notification_channels") or {}
if user is None:
return {}
if user.admin:
return dict(all_channels)
visible = {}
for name, cfg in all_channels.items():
if not isinstance(cfg, dict):
continue
if not cfg.get("private") or cfg.get("owner") == user.username:
visible[name] = cfg
return visible
def _build_channel_response(ch_name, ch_cfg):
"""Serialize a channel config dict for the API response."""
ch_type = ch_cfg.get("type", "")
schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", [])
fields = []
for sf in schema_fields:
k = sf["key"]
v = ch_cfg.get(k, "")
sensitive = sf["type"] == "secret"
fields.append({
"key": k,
"label": sf["label"],
"value": "•••" if (sensitive and v) else (
", ".join(v) if isinstance(v, list) else str(v or "")
),
"sensitive": sensitive,
})
return {
"name": ch_name,
"type": ch_type,
"type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
"owner": ch_cfg.get("owner"),
"private": bool(ch_cfg.get("private", False)),
"min_level": ch_cfg.get("min_level", "WARNING"),
"fields": fields,
}
# -------------------------------------------------------------------------
# Notification channel API (any authenticated user)
# -------------------------------------------------------------------------
async def api_notification_channel_types(request):
"""GET /api/0/notification_channel_types — channel type schemas."""
user, err = _require_auth(request)
if err:
return err
return web.json_response(settings_mod.CHANNEL_TYPE_SCHEMAS)
async def api_notification_channels_get(request):
"""GET /api/0/notification_channels — list channels visible to current user."""
user, err = _require_auth(request)
if err:
return err
visible = _visible_channels_for_user(user)
result = [
_build_channel_response(name, cfg)
for name, cfg in visible.items()
if isinstance(cfg, dict)
]
return web.json_response(result)
async def api_notification_channels_post(request):
"""POST /api/0/notification_channels — create a new channel."""
user, err = _require_auth(request)
if err:
return err
if user is None:
return web.json_response({"error": "Authentication required"}, status=401)
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
name = (body.get("name") or "").strip()
if not name:
return web.json_response({"error": "Channel name is required"}, status=400)
ch_type = (body.get("type") or "").strip()
if ch_type not in settings_mod.CHANNEL_TYPE_SCHEMAS:
return web.json_response({"error": f"Unknown channel type: {ch_type!r}"}, status=400)
if name in (config.get("notification_channels") or {}):
return web.json_response({"error": f"Channel {name!r} already exists"}, status=409)
schema = settings_mod.CHANNEL_TYPE_SCHEMAS[ch_type]
channel_cfg = {"type": ch_type}
for sf in schema["fields"]:
k = sf["key"]
v = body.get(k, "")
if v:
channel_cfg[k] = v
elif sf["required"]:
return web.json_response({"error": f"Field {k!r} is required"}, status=400)
if body.get("min_level"):
channel_cfg["min_level"] = body["min_level"]
channel_cfg["owner"] = user.username
if body.get("private"):
channel_cfg["private"] = True
try:
disk_data = configio_mod.read_roundtrip(_config_path)
configio_mod.apply_channel(disk_data, name, channel_cfg)
configio_mod.write_config(_config_path, disk_data)
except Exception as exc:
logger.error("Channel create failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
if hasattr(config, "reload"):
await config.reload()
return web.json_response({"ok": True, "name": name})
async def api_notification_channel_put(request):
"""PUT /api/0/notification_channels/{name} — update a channel."""
user, err = _require_auth(request)
if err:
return err
if user is None:
return web.json_response({"error": "Authentication required"}, status=401)
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
ch_name = request.match_info["name"]
existing_channels = config.get("notification_channels") or {}
if ch_name not in existing_channels:
return web.json_response({"error": f"Channel {ch_name!r} not found"}, status=404)
existing_cfg = existing_channels[ch_name]
if not isinstance(existing_cfg, dict):
return web.json_response({"error": "Invalid channel config"}, status=500)
owner = existing_cfg.get("owner")
if not user.admin and owner != user.username:
return web.json_response({"error": "Forbidden"}, status=403)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
ch_type = existing_cfg.get("type", "")
schema_fields = settings_mod.CHANNEL_TYPE_SCHEMAS.get(ch_type, {}).get("fields", [])
secret_keys = {sf["key"] for sf in schema_fields if sf["type"] == "secret"}
try:
disk_data = configio_mod.read_roundtrip(_config_path)
existing_on_disk = (disk_data.get("notification_channels") or {}).get(ch_name, {})
channel_cfg = {"type": ch_type}
for sf in schema_fields:
k = sf["key"]
v = body.get(k, "")
if k in secret_keys and (not v or v == "•••"):
existing_val = existing_on_disk.get(k, "")
if existing_val:
channel_cfg[k] = existing_val
elif v:
channel_cfg[k] = v
if body.get("min_level"):
channel_cfg["min_level"] = body["min_level"]
if owner is not None:
channel_cfg["owner"] = owner
if "private" in body:
channel_cfg["private"] = bool(body["private"])
elif existing_on_disk.get("private"):
channel_cfg["private"] = True
configio_mod.apply_channel(disk_data, ch_name, channel_cfg)
configio_mod.write_config(_config_path, disk_data)
except Exception as exc:
logger.error("Channel update failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
if hasattr(config, "reload"):
await config.reload()
return web.json_response({"ok": True})
async def api_notification_channel_delete(request):
"""DELETE /api/0/notification_channels/{name} — delete a channel."""
user, err = _require_auth(request)
if err:
return err
if user is None:
return web.json_response({"error": "Authentication required"}, status=401)
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
ch_name = request.match_info["name"]
existing_channels = config.get("notification_channels") or {}
if ch_name not in existing_channels:
return web.json_response({"error": f"Channel {ch_name!r} not found"}, status=404)
existing_cfg = existing_channels[ch_name]
owner = existing_cfg.get("owner") if isinstance(existing_cfg, dict) else None
if not user.admin and owner != user.username:
return web.json_response({"error": "Forbidden"}, status=403)
try:
disk_data = configio_mod.read_roundtrip(_config_path)
configio_mod.delete_channel(disk_data, ch_name)
configio_mod.write_config(_config_path, disk_data)
except Exception as exc:
logger.error("Channel delete failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
if hasattr(config, "reload"):
await config.reload()
return web.json_response({"ok": True})
async def api_user_self_put(request):
"""PUT /api/0/users/me — update own full_name, avatar, notification_channels, password."""
user, err = _require_auth(request)
@@ -1297,7 +1534,10 @@ async def start(
if "avatar" in body:
user_entry["avatar"] = str(body["avatar"])
if "notification_channels" in body:
user_entry["notification_channels"] = [str(ch) for ch in body["notification_channels"]]
visible = _visible_channels_for_user(user)
user_entry["notification_channels"] = [
str(ch) for ch in body["notification_channels"] if ch in visible
]
if password_change:
user_entry["password"] = users_mod.hash_password(password_change["new"])
@@ -1337,6 +1577,12 @@ async def start(
web.get("/api/0/config/backups", api_config_backups_get),
web.post("/api/0/config", api_config_post),
web.post("/api/0/config/rollback", api_config_rollback),
# Notification channel API (any authenticated user)
web.get("/api/0/notification_channel_types", api_notification_channel_types),
web.get("/api/0/notification_channels", api_notification_channels_get),
web.post("/api/0/notification_channels", api_notification_channels_post),
web.put("/api/0/notification_channels/{name}", api_notification_channel_put),
web.delete("/api/0/notification_channels/{name}", api_notification_channel_delete),
# Hosts
web.get("/api/0/hosts", api_hosts),
web.get("/api/0/alert_summary", api_alert_summary),
+3
View File
@@ -366,6 +366,9 @@ _TIMEOUT = 15 # seconds per channel send
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
"""Send *notif* to a single named channel, honouring min_level."""
# Strip ownership metadata — notifier drivers only need delivery credentials.
channel_cfg = {k: v for k, v in channel_cfg.items() if k not in ("owner", "private")}
level = notif.level.upper()
if level != "RECOVER":
min_level = channel_cfg.get("min_level", "WARNING").upper()
+63 -7
View File
@@ -27,13 +27,65 @@ _SECRET_KEYS = frozenset({
"smtp_password", "smtp_user", "api_password", "access_token",
})
_CHANNEL_TYPE_LABELS = {
"pushover": "Pushover",
"email": "E-mail",
"signal": "Signal",
"mattermost": "Mattermost",
CHANNEL_TYPE_SCHEMAS = {
"pushover": {
"label": "Pushover",
"fields": [
{"key": "token", "label": "App token", "type": "secret", "required": True},
{"key": "user", "label": "User key", "type": "secret", "required": True},
{"key": "sound", "label": "Sound", "type": "text", "required": False},
],
},
"email": {
"label": "E-mail",
"fields": [
{"key": "recipients", "label": "Recipients (comma-separated)", "type": "list", "required": True},
{"key": "sender", "label": "From address", "type": "text", "required": True},
{"key": "smtp_server", "label": "SMTP server", "type": "text", "required": True},
{"key": "smtp_port", "label": "SMTP port", "type": "port", "required": False},
{"key": "smtp_user", "label": "SMTP username", "type": "text", "required": False},
{"key": "smtp_password", "label": "SMTP password", "type": "secret", "required": False},
],
},
"signal": {
"label": "Signal",
"fields": [
{"key": "user", "label": "Sender number", "type": "text", "required": True},
{"key": "recipient", "label": "Recipient number", "type": "text", "required": True},
{"key": "cli_path", "label": "signal-cli path", "type": "text", "required": False},
],
},
"matrix": {
"label": "Matrix",
"fields": [
{"key": "homeserver", "label": "Homeserver URL", "type": "text", "required": True},
{"key": "access_token", "label": "Access token", "type": "secret", "required": True},
{"key": "room_id", "label": "Room ID", "type": "text", "required": True},
],
},
"sms_voipms": {
"label": "SMS (voip.ms)",
"fields": [
{"key": "api_user", "label": "API username", "type": "text", "required": True},
{"key": "api_password", "label": "API password", "type": "secret", "required": True},
{"key": "did", "label": "DID (from)", "type": "text", "required": True},
{"key": "dst", "label": "Destination", "type": "text", "required": True},
],
},
"mattermost": {
"label": "Mattermost",
"fields": [
{"key": "host", "label": "Host", "type": "text", "required": True},
{"key": "token", "label": "Webhook token", "type": "secret", "required": True},
{"key": "channel", "label": "Channel", "type": "text", "required": True},
{"key": "username", "label": "Bot username", "type": "text", "required": False},
{"key": "icon", "label": "Icon URL", "type": "text", "required": False},
],
},
}
_CHANNEL_TYPE_LABELS = {k: v["label"] for k, v in CHANNEL_TYPE_SCHEMAS.items()}
def _mask(value):
"""Return a masked placeholder for sensitive values."""
@@ -143,6 +195,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
}
# ---- Notification channels (complex, built separately) ----------------
_METADATA_KEYS = {"type", "owner", "private", "min_level"}
notif_channels = []
for ch_name, ch_cfg in (config.get("notification_channels") or {}).items():
if not isinstance(ch_cfg, dict):
@@ -150,7 +203,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
ch_type = ch_cfg.get("type", "")
fields = []
for k, v in ch_cfg.items():
if k == "type":
if k in _METADATA_KEYS:
continue
sensitive = k in _SECRET_KEYS
fields.append({
@@ -165,6 +218,9 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"name": ch_name,
"type": ch_type,
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
"owner": ch_cfg.get("owner"),
"private": bool(ch_cfg.get("private", False)),
"min_level": ch_cfg.get("min_level", "WARNING"),
"fields": fields,
})
@@ -368,7 +424,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "channels",
"title": "Notification Channels",
"description": "Named notification providers. Credentials are masked.",
"section_mode": "yaml",
"section_mode": "channels",
"api_section": "notification_channels",
"channels": notif_channels,
"fields": [
+319 -23
View File
@@ -215,11 +215,59 @@
.save-row { display: flex; align-items: center; margin-top: 8px; }
.btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; }
.btn-save:hover { background: #0055aa; }
.channel-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; }
.channel-item:last-child { border-bottom: none; }
.channel-item label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; font-size: .88em; }
.channel-item .ch-name { font-weight: 500; color: #222; }
.channel-item .ch-meta { font-size: .8em; color: #888; }
/* ---- Channel chip picker ---- */
.ch-picker { }
.ch-picker-label { font-size: .8em; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; }
.ch-chips { display: flex; flex-wrap: wrap; gap: 6px; min-height: 32px; margin-bottom: 10px; }
.ch-chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 14px; font-size: .85em; font-weight: 500; cursor: pointer;
border: none; font-family: inherit;
}
.ch-chip.selected { background: #e3f2fd; color: #1565c0; }
.ch-chip.selected:hover { background: #bbdefb; }
.ch-chip.available { background: #f1f3f4; color: #555; }
.ch-chip.available:hover { background: #e8eaf6; color: #283593; }
.ch-chip-x { font-size: .9em; line-height: 1; color: inherit; opacity: .7; }
/* ---- My Channels card list ---- */
.my-ch-card {
border: 1px solid #e8eaf6; border-radius: 6px; margin-bottom: 8px; overflow: hidden;
}
.my-ch-header {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: #f8f9ff; border-bottom: 1px solid #e8eaf6;
}
.my-ch-name { font-weight: 600; font-size: .9em; color: #222; }
.my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; }
.my-ch-private { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
.my-ch-actions { margin-left: auto; display: flex; gap: 5px; }
.btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; }
.btn-sm-edit:hover { background: #666; }
.btn-sm-del { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: .78em; cursor: pointer; }
.btn-sm-del:hover { background: #fce4ec; }
/* ---- Channel modal (for My Channels CRUD) ---- */
.ch-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
display: flex; align-items: center; justify-content: center; z-index: 1001;
}
.ch-modal-box {
background: #fff; border-radius: 8px; padding: 24px;
min-width: 360px; max-width: 520px; width: 95%;
box-shadow: 0 8px 32px rgba(0,0,0,.2);
}
.ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; }
.ch-form-row { margin-bottom: 12px; }
.ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; }
.ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select {
width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px;
font-size: .88em; box-sizing: border-box; font-family: inherit;
}
.ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; }
.ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; }
.ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.ch-modal-status { font-size: .83em; margin-top: 8px; }
</style>
<body>
@@ -318,37 +366,117 @@
</div>
{% endif %}
<!-- Notification channels -->
<!-- Notification channels — chip picker -->
<div class="section">
<h2>Notification Channels</h2>
{% if current_user %}
<p style="font-size:.82em;color:#888;margin:0 0 10px">Select which channels send you alerts. Channels are defined by the administrator.</p>
{% if all_channel_names %}
<div id="channel-checkboxes">
{% for ch_name in all_channel_names %}
<div class="channel-item">
<label>
<input type="checkbox" class="channel-checkbox" value="{{ ch_name | e }}"
{% if ch_name in (current_user.notification_channels or []) %}checked{% endif %}>
<div>
<div class="ch-name">{{ ch_name | e }}</div>
</div>
</label>
<p style="font-size:.82em;color:#888;margin:0 0 12px">Click a channel to add or remove it from your alert list.</p>
{% if all_channels %}
<div class="ch-picker">
<div class="ch-picker-label">Selected</div>
<div id="selected-chips" class="ch-chips">
{% for ch in all_channels %}
{% if ch.name in (current_user.notification_channels or []) %}
<button class="ch-chip selected" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
{{ ch.name | e }} <span class="ch-chip-x">×</span>
</button>
{% endif %}
{% endfor %}
{% set selected_set = current_user.notification_channels or [] %}
{% set has_selected = selected_set | length > 0 %}
{% if not has_selected %}
<span style="font-size:.83em;color:#bbb;font-style:italic;align-self:center">None selected</span>
{% endif %}
</div>
<div class="ch-picker-label">Available</div>
<div id="available-chips" class="ch-chips">
{% for ch in all_channels %}
{% if ch.name not in (current_user.notification_channels or []) %}
<button class="ch-chip available" data-ch="{{ ch.name | e }}" onclick="toggleChip(this)">
+ {{ ch.name | e }}
</button>
{% endif %}
{% endfor %}
</div>
</div>
{% else %}
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels configured.</p>
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels available. You can create your own below.</p>
{% endif %}
<div class="save-row" style="margin-top:10px">
<div class="save-row">
<button class="btn-save" onclick="saveChannels()">Save channels</button>
<span id="channels-status" class="status-msg"></span>
</div>
{% else %}
<span class="no-hosts">No personal notification channels configured.</span>
<span class="no-hosts">Log in to manage notification channels.</span>
{% endif %}
</div>
<!-- My Channels — create/edit/delete own channels -->
{% if current_user %}
<div class="section">
<h2>My Channels</h2>
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own. Public channels are available to all users; private channels are visible only to you.</p>
<div id="my-channels-list">
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
{% for ch in my_channels %}
<div class="my-ch-card" id="mychcard-{{ ch.name | e }}">
<div class="my-ch-header">
<span class="my-ch-name">{{ ch.name | e }}</span>
<span class="my-ch-type">{{ ch.type | e }}</span>
{% if ch.private %}<span class="my-ch-private">private</span>{% endif %}
<span class="my-ch-actions">
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')"></button>
</span>
</div>
</div>
{% endfor %}
{% if not my_channels %}
<p id="my-channels-empty" style="font-size:.83em;color:#bbb;font-style:italic">No channels yet.</p>
{% endif %}
</div>
<div class="save-row" style="margin-top:8px">
<button class="btn-save" onclick="openMyChModal()">+ New channel</button>
</div>
</div>
<!-- My Channels modal -->
<div id="my-ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeMyChModal()">
<div class="ch-modal-box">
<h3 id="my-ch-modal-title">New Channel</h3>
<div class="ch-form-row">
<label>Channel name</label>
<input type="text" id="my-ch-name" placeholder="e.g. my_pushover" autocomplete="off">
</div>
<div class="ch-form-row">
<label>Type</label>
<select id="my-ch-type" onchange="onMyChTypeChange()">
<option value="">— select —</option>
</select>
</div>
<div id="my-ch-type-fields"></div>
<div class="ch-form-divider">Options</div>
<div class="ch-form-row">
<label>Minimum alert level</label>
<select id="my-ch-min-level">
<option value="WARNING">WARNING (and above)</option>
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
<div class="ch-form-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="my-ch-private"> Private — visible only to you
</label>
</div>
<div id="my-ch-modal-status" class="ch-modal-status"></div>
<div class="ch-modal-footer">
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
<button class="btn-save" onclick="saveMyChannel()">Save</button>
</div>
</div>
</div>
{% endif %}
<!-- Host access -->
<div class="section">
<h2>Host Access</h2>
@@ -395,6 +523,7 @@
</div>
<script>
// ---- Identity ----
async function saveIdentity() {
const full_name = document.getElementById('profile-fullname').value;
const avatar = document.getElementById('profile-avatar').value;
@@ -411,6 +540,7 @@
}
}
// ---- Password ----
async function changePassword() {
const current = document.getElementById('profile-current-pw').value;
const newpw = document.getElementById('profile-new-pw').value;
@@ -433,9 +563,37 @@
}
}
// ---- Channel chip picker ----
function toggleChip(btn) {
const name = btn.dataset.ch;
const isSelected = btn.classList.contains('selected');
if (isSelected) {
// Move to available
btn.classList.remove('selected');
btn.classList.add('available');
btn.innerHTML = '+ ' + escHtml(name);
btn.onclick = function() { toggleChip(this); };
document.getElementById('available-chips').appendChild(btn);
// Remove "None selected" placeholder if it exists
} else {
// Move to selected
btn.classList.remove('available');
btn.classList.add('selected');
btn.innerHTML = escHtml(name) + ' <span class="ch-chip-x">×</span>';
btn.onclick = function() { toggleChip(this); };
document.getElementById('selected-chips').appendChild(btn);
}
// Update placeholder visibility
const sel = document.getElementById('selected-chips');
const placeholder = sel.querySelector('span[style]');
const hasChips = sel.querySelectorAll('.ch-chip.selected').length > 0;
if (placeholder) placeholder.style.display = hasChips ? 'none' : '';
}
async function saveChannels() {
const notification_channels = [...document.querySelectorAll('.channel-checkbox:checked')]
.map(cb => cb.value);
const notification_channels = [
...document.querySelectorAll('#selected-chips .ch-chip.selected')
].map(b => b.dataset.ch);
const resp = await fetch('/api/0/users/me', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
@@ -449,6 +607,138 @@
}
}
// ---- My Channels CRUD ----
let _myChSchemas = {};
let _myChEditName = null;
async function _loadMyChSchemas() {
try {
const r = await fetch('/api/0/notification_channel_types');
_myChSchemas = await r.json();
const sel = document.getElementById('my-ch-type');
if (!sel) return;
Object.entries(_myChSchemas).forEach(([k, v]) => {
const opt = document.createElement('option');
opt.value = k; opt.textContent = v.label;
sel.appendChild(opt);
});
} catch(e) { console.warn('Could not load channel schemas', e); }
}
function onMyChTypeChange() {
const type = document.getElementById('my-ch-type').value;
const container = document.getElementById('my-ch-type-fields');
container.innerHTML = '';
if (!type || !_myChSchemas[type]) return;
const divider = document.createElement('div');
divider.className = 'ch-form-divider';
divider.textContent = _myChSchemas[type].label + ' settings';
container.appendChild(divider);
(_myChSchemas[type].fields || []).forEach(sf => {
const row = document.createElement('div');
row.className = 'ch-form-row';
const lbl = document.createElement('label');
lbl.textContent = sf.label + (sf.required ? ' *' : '');
const inp = document.createElement('input');
inp.type = sf.type === 'secret' ? 'password' : 'text';
inp.id = 'mychf-' + sf.key;
inp.placeholder = sf.required ? '(required)' : '(optional)';
inp.autocomplete = 'off';
row.appendChild(lbl);
row.appendChild(inp);
container.appendChild(row);
});
}
async function openMyChModal(name) {
_myChEditName = name || null;
document.getElementById('my-ch-modal-status').textContent = '';
document.getElementById('my-ch-modal-title').textContent = name ? 'Edit Channel' : 'New Channel';
document.getElementById('my-ch-name').value = name || '';
document.getElementById('my-ch-name').disabled = !!name;
document.getElementById('my-ch-type').value = '';
document.getElementById('my-ch-type-fields').innerHTML = '';
document.getElementById('my-ch-min-level').value = 'WARNING';
document.getElementById('my-ch-private').checked = false;
if (name) {
try {
const r = await fetch('/api/0/notification_channels');
const channels = await r.json();
const ch = channels.find(c => c.name === name);
if (ch) {
document.getElementById('my-ch-type').value = ch.type;
onMyChTypeChange();
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
document.getElementById('my-ch-private').checked = ch.private || false;
(ch.fields || []).forEach(f => {
const inp = document.getElementById('mychf-' + f.key);
if (inp) inp.value = f.value || '';
});
}
} catch(e) { console.warn('Failed to load channel', e); }
}
document.getElementById('my-ch-modal').style.display = 'flex';
}
function closeMyChModal() {
document.getElementById('my-ch-modal').style.display = 'none';
}
async function saveMyChannel() {
const name = document.getElementById('my-ch-name').value.trim();
const type = document.getElementById('my-ch-type').value;
const minLevel = document.getElementById('my-ch-min-level').value;
const isPrivate = document.getElementById('my-ch-private').checked;
const statusEl = document.getElementById('my-ch-modal-status');
statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Name is required.'; statusEl.style.color = '#c62828'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
const body = { name, type, min_level: minLevel, private: isPrivate };
if (_myChSchemas[type]) {
(_myChSchemas[type].fields || []).forEach(sf => {
const inp = document.getElementById('mychf-' + sf.key);
if (inp) body[sf.key] = inp.value;
});
}
const isEdit = !!_myChEditName;
const url = isEdit
? '/api/0/notification_channels/' + encodeURIComponent(_myChEditName)
: '/api/0/notification_channels';
const method = isEdit ? 'PUT' : 'POST';
try {
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
if (r.ok) {
closeMyChModal();
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
statusEl.textContent = err.error || 'Error saving.';
statusEl.style.color = '#c62828';
}
} catch(e) {
statusEl.textContent = 'Network error: ' + e.message;
statusEl.style.color = '#c62828';
}
}
async function deleteMyChannel(name) {
if (!confirm('Delete channel "' + name + '"?')) return;
try {
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
if (r.ok) {
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
alert('Error: ' + (err.error || 'Could not delete.'));
}
} catch(e) { alert('Network error: ' + e.message); }
}
// ---- Utilities ----
function showStatus(id, msg, color) {
const el = document.getElementById(id);
if (!el) return;
@@ -456,6 +746,12 @@
el.style.color = color;
setTimeout(() => { el.textContent = ''; }, 3000);
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
document.addEventListener('DOMContentLoaded', _loadMyChSchemas);
</script>
</body>
</html>
+243
View File
@@ -207,6 +207,36 @@
.channel-field-label { width: 130px; flex-shrink: 0; color: #777; }
.channel-field-value { color: #333; word-break: break-all; }
/* ---- Channel management (form-based section) ---- */
.channel-header-actions { margin-left: auto; display: flex; gap: 6px; }
.ch-owner-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8f5e9; color: #2e7d32; }
.ch-private-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
.ch-level-badge { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fff3e0; color: #e65100; }
.channel-grid { padding: 12px 20px 0; }
.channel-add-bar { display: flex; justify-content: flex-end; padding: 10px 20px; border-top: 1px solid #f0f0f0; }
/* Channel modal */
.ch-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
display: flex; align-items: center; justify-content: center; z-index: 1001;
}
.ch-modal-box {
background: #fff; border-radius: 8px; padding: 24px;
min-width: 360px; max-width: 520px; width: 95%;
box-shadow: 0 8px 32px rgba(0,0,0,.2);
}
.ch-modal-box h3 { margin: 0 0 16px; font-size: 1em; }
.ch-form-row { margin-bottom: 12px; }
.ch-form-row label { display: block; font-size: .83em; font-weight: 600; color: #555; margin-bottom: 3px; }
.ch-form-row input[type=text], .ch-form-row input[type=password], .ch-form-row select {
width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px;
font-size: .88em; box-sizing: border-box; font-family: inherit;
}
.ch-form-row input:focus, .ch-form-row select:focus { border-color: #0066cc; outline: none; }
.ch-form-divider { font-size: .78em; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #888; margin: 14px 0 8px; border-top: 1px solid #eee; padding-top: 10px; }
.ch-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.ch-status { font-size: .83em; margin-top: 8px; }
/* ---- Hosts table ---- */
/* ---- Mobile: collapsible sidebar ---- */
.sidebar-toggle {
@@ -381,6 +411,42 @@
</div>
</div>
<!-- Channel add/edit modal -->
<div id="ch-modal" class="ch-modal-overlay" style="display:none" onclick="if(event.target===this)closeChannelModal()">
<div class="ch-modal-box">
<h3 id="ch-modal-title">Add Notification Channel</h3>
<div class="ch-form-row">
<label>Channel name</label>
<input type="text" id="ch-name" placeholder="e.g. pushover_ops" autocomplete="off">
</div>
<div class="ch-form-row">
<label>Type</label>
<select id="ch-type" onchange="onChTypeChange()">
<option value="">— select —</option>
</select>
</div>
<div id="ch-type-fields"></div>
<div class="ch-form-divider">Options</div>
<div class="ch-form-row">
<label>Minimum alert level</label>
<select id="ch-min-level">
<option value="WARNING">WARNING (and above)</option>
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
<div class="ch-form-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="ch-private"> Private — visible only to you
</label>
</div>
<div id="ch-modal-status" class="ch-status"></div>
<div class="ch-modal-footer">
<button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveChannel()">Save</button>
</div>
</div>
</div>
<div class="settings-layout">
<!-- Sidebar navigation -->
@@ -488,6 +554,52 @@
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
</div>
{# ---- Notification channels (form-based, live CRUD) ---- #}
{% elif section.section_mode == 'channels' %}
{% for f in section.fields %}
<div class="field-row" style="border-bottom:1px solid #f0f0f0">
<div class="field-label">{{ f.label }}</div>
<div class="field-body">
{% if f.type == 'list' %}
{% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
{% else %}<span class="val-empty">None</span>{% endif %}
{% else %}
<div class="field-value">{{ f.value if f.value is not none else '' }}</div>
{% endif %}
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
</div>
</div>
{% endfor %}
<div class="channel-grid" id="channel-cards">
{% for ch in section.channels %}
<div class="channel-card" id="chcard-{{ ch.name | e }}">
<div class="channel-header">
<span class="channel-name-text">{{ ch.name | e }}</span>
<span class="ch-type-badge">{{ ch.type_label | e }}</span>
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
{% if ch.private %}<span class="ch-private-badge">private</span>{% endif %}
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
<span class="channel-header-actions">
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')"></button>
</span>
</div>
<div class="channel-fields">
{% for f in ch.fields %}
<div class="channel-field">
<span class="channel-field-label">{{ f.label }}</span>
<span class="channel-field-value">{% if f.sensitive %}<span class="val-masked">•••</span>{% elif f.value %}{{ f.value | e }}{% else %}<span style="color:#ccc"></span>{% endif %}</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% if not section.channels %}<p style="color:#aaa;font-size:.88em;padding:12px 0">No channels configured yet.</p>{% endif %}
</div>
<div class="channel-add-bar">
<button class="btn btn-primary" onclick="openChannelModal()">+ Add channel</button>
</div>
{# ---- YAML editor section ---- #}
{% elif section.section_mode == 'yaml' %}
<div style="padding: 12px 20px">
@@ -557,6 +669,136 @@
// ---- Channel names for add-user row ----
const _allChannels = {{ all_channel_names | tojson }};
// ---- Channel CRUD ----
let _channelSchemas = {};
let _chEditName = null; // null = create mode, string = edit mode
async function _loadChannelSchemas() {
try {
const r = await fetch('/api/0/notification_channel_types');
_channelSchemas = await r.json();
const sel = document.getElementById('ch-type');
if (!sel) return;
Object.entries(_channelSchemas).forEach(([k, v]) => {
const opt = document.createElement('option');
opt.value = k; opt.textContent = v.label;
sel.appendChild(opt);
});
} catch(e) { console.warn('Could not load channel schemas', e); }
}
function onChTypeChange() {
const type = document.getElementById('ch-type').value;
const container = document.getElementById('ch-type-fields');
container.innerHTML = '';
if (!type || !_channelSchemas[type]) return;
const divider = document.createElement('div');
divider.className = 'ch-form-divider';
divider.textContent = _channelSchemas[type].label + ' settings';
container.appendChild(divider);
(_channelSchemas[type].fields || []).forEach(sf => {
const row = document.createElement('div');
row.className = 'ch-form-row';
const lbl = document.createElement('label');
lbl.textContent = sf.label + (sf.required ? ' *' : '');
const inp = document.createElement(sf.type === 'secret' ? 'input' : 'input');
inp.type = sf.type === 'secret' ? 'password' : 'text';
inp.id = 'chf-' + sf.key;
inp.placeholder = sf.required ? '(required)' : '(optional)';
inp.autocomplete = 'off';
row.appendChild(lbl);
row.appendChild(inp);
container.appendChild(row);
});
}
async function openChannelModal(name) {
_chEditName = name || null;
document.getElementById('ch-modal-status').textContent = '';
document.getElementById('ch-modal-title').textContent = name ? 'Edit Channel' : 'Add Notification Channel';
document.getElementById('ch-name').value = name || '';
document.getElementById('ch-name').disabled = !!name;
document.getElementById('ch-type').value = '';
document.getElementById('ch-type-fields').innerHTML = '';
document.getElementById('ch-min-level').value = 'WARNING';
document.getElementById('ch-private').checked = false;
if (name) {
// Load existing channel data via API
try {
const r = await fetch('/api/0/notification_channels');
const channels = await r.json();
const ch = channels.find(c => c.name === name);
if (ch) {
document.getElementById('ch-type').value = ch.type;
onChTypeChange();
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
document.getElementById('ch-private').checked = ch.private || false;
(ch.fields || []).forEach(f => {
const inp = document.getElementById('chf-' + f.key);
if (inp) inp.value = f.value || '';
});
}
} catch(e) { console.warn('Failed to load channel data', e); }
}
document.getElementById('ch-modal').style.display = 'flex';
}
function closeChannelModal() {
document.getElementById('ch-modal').style.display = 'none';
}
async function saveChannel() {
const name = document.getElementById('ch-name').value.trim();
const type = document.getElementById('ch-type').value;
const minLevel = document.getElementById('ch-min-level').value;
const isPrivate = document.getElementById('ch-private').checked;
const statusEl = document.getElementById('ch-modal-status');
statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
const body = { name, type, min_level: minLevel, private: isPrivate };
if (_channelSchemas[type]) {
(_channelSchemas[type].fields || []).forEach(sf => {
const inp = document.getElementById('chf-' + sf.key);
if (inp) body[sf.key] = inp.value;
});
}
const isEdit = !!_chEditName;
const url = isEdit ? '/api/0/notification_channels/' + encodeURIComponent(_chEditName) : '/api/0/notification_channels';
const method = isEdit ? 'PUT' : 'POST';
try {
const r = await fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
if (r.ok) {
closeChannelModal();
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
statusEl.textContent = err.error || 'Error saving channel.';
statusEl.style.color = '#c62828';
}
} catch(e) {
statusEl.textContent = 'Network error: ' + e.message;
statusEl.style.color = '#c62828';
}
}
async function deleteChannel(name) {
if (!confirm('Delete channel "' + name + '"? This cannot be undone.')) return;
try {
const r = await fetch('/api/0/notification_channels/' + encodeURIComponent(name), { method: 'DELETE' });
if (r.ok) {
window.location.reload();
} else {
const err = await r.json().catch(() => ({}));
alert('Error: ' + (err.error || 'Could not delete channel.'));
}
} catch(e) { alert('Network error: ' + e.message); }
}
// ---- Staged changes accumulator ----
const _staged = {};
@@ -707,6 +949,7 @@
}
document.addEventListener('DOMContentLoaded', () => {
_loadChannelSchemas();
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
const sectionId = ta.id.replace('yaml-', '');
const section = document.getElementById(sectionId);
+38
View File
@@ -83,3 +83,41 @@ def test_put_users_me_notification_channels(tmp_path):
configio.write_config(str(cfg), data)
result = configio.read_roundtrip(str(cfg))
assert result["users"]["alice"]["notification_channels"] == ["pushover_ops"]
def test_visible_channels_excludes_private_from_others():
"""Private channels owned by another user must not appear in the visible set."""
from hbd.server import settings as settings_mod
config = {
"notification_channels": {
"public_ch": {"type": "pushover", "token": "t", "user": "u"},
"alice_priv": {"type": "email", "owner": "alice", "private": True,
"recipients": ["a@b.com"], "sender": "s@b.com", "smtp_server": "s"},
"bob_priv": {"type": "email", "owner": "bob", "private": True,
"recipients": ["b@b.com"], "sender": "s@b.com", "smtp_server": "s"},
}
}
class FakeUser:
def __init__(self, username, admin=False):
self.username = username
self.admin = admin
alice = FakeUser("alice")
bob = FakeUser("bob")
admin = FakeUser("admin", admin=True)
# Simulate _visible_channels_for_user logic (mirrors http.py implementation)
def visible(user):
all_channels = config.get("notification_channels") or {}
if user.admin:
return set(all_channels.keys())
return {
name for name, cfg in all_channels.items()
if not cfg.get("private") or cfg.get("owner") == user.username
}
assert visible(alice) == {"public_ch", "alice_priv"}
assert visible(bob) == {"public_ch", "bob_priv"}
assert visible(admin) == {"public_ch", "alice_priv", "bob_priv"}
+178
View File
@@ -0,0 +1,178 @@
"""Tests for notification channel CRUD via configio helpers and visibility logic."""
import pytest
from hbd.server import configio, settings as settings_mod
SAMPLE_YAML = """\
hbd_port: 50004
notification_channels:
pushover_ops:
type: pushover
token: abc123
user: usr456
"""
# ---------------------------------------------------------------------------
# configio helpers
# ---------------------------------------------------------------------------
def test_apply_channel_adds_new_entry(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_channel(data, "email_ops", {"type": "email", "recipients": ["ops@example.com"]})
assert "email_ops" in data["notification_channels"]
assert data["notification_channels"]["email_ops"]["type"] == "email"
# Existing channel preserved
assert "pushover_ops" in data["notification_channels"]
def test_apply_channel_updates_existing(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_channel(data, "pushover_ops", {"type": "pushover", "token": "new_tok", "user": "new_usr"})
assert data["notification_channels"]["pushover_ops"]["token"] == "new_tok"
def test_apply_channel_creates_section_if_absent():
data = {"hbd_port": 50004}
configio.apply_channel(data, "test_ch", {"type": "pushover", "token": "t", "user": "u"})
assert "notification_channels" in data
assert "test_ch" in data["notification_channels"]
def test_delete_channel_removes_entry(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.delete_channel(data, "pushover_ops")
assert "pushover_ops" not in data["notification_channels"]
def test_delete_channel_noop_for_missing():
data = {"notification_channels": {"ch1": {"type": "pushover"}}}
configio.delete_channel(data, "nonexistent") # must not raise
assert "ch1" in data["notification_channels"]
def test_delete_channel_noop_when_no_section():
data = {}
configio.delete_channel(data, "anything") # must not raise
def test_apply_channel_persisted_after_write(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_channel(data, "signal_ops", {"type": "signal", "user": "+1", "recipient": "+2"})
configio.write_config(str(f), data)
result = configio.read_roundtrip(str(f))
assert "signal_ops" in result["notification_channels"]
assert result["notification_channels"]["signal_ops"]["user"] == "+1"
# Original channel preserved
assert "pushover_ops" in result["notification_channels"]
def test_delete_channel_persisted_after_write(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.delete_channel(data, "pushover_ops")
configio.write_config(str(f), data)
result = configio.read_roundtrip(str(f))
assert "pushover_ops" not in (result.get("notification_channels") or {})
# ---------------------------------------------------------------------------
# Visibility logic (mirrors http.py _visible_channels_for_user)
# ---------------------------------------------------------------------------
def _visible(config, user):
"""Local copy of the visibility helper for unit testing without the HTTP layer."""
all_channels = config.get("notification_channels") or {}
if user.get("admin"):
return set(all_channels.keys())
username = user["username"]
return {
name for name, cfg in all_channels.items()
if isinstance(cfg, dict) and (not cfg.get("private") or cfg.get("owner") == username)
}
CONFIG_VISIBILITY = {
"notification_channels": {
"pub_ch": {"type": "pushover", "token": "t", "user": "u"},
"alice_priv": {"type": "email", "owner": "alice", "private": True,
"recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"},
"bob_priv": {"type": "signal", "owner": "bob", "private": True,
"user": "+1", "recipient": "+2"},
"admin_owned": {"type": "pushover", "token": "t2", "user": "u2", "owner": "adminuser"},
}
}
def test_public_channel_visible_to_all():
for uname in ("alice", "bob", "carol"):
user = {"username": uname, "admin": False}
assert "pub_ch" in _visible(CONFIG_VISIBILITY, user)
def test_private_channel_visible_only_to_owner():
alice = {"username": "alice", "admin": False}
bob = {"username": "bob", "admin": False}
carol = {"username": "carol", "admin": False}
assert "alice_priv" in _visible(CONFIG_VISIBILITY, alice)
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, bob)
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, carol)
assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob)
assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice)
def test_admin_sees_all_channels():
admin = {"username": "adminuser", "admin": True}
visible = _visible(CONFIG_VISIBILITY, admin)
assert visible == {"pub_ch", "alice_priv", "bob_priv", "admin_owned"}
def test_admin_owned_channel_is_public_by_default():
alice = {"username": "alice", "admin": False}
assert "admin_owned" in _visible(CONFIG_VISIBILITY, alice)
# ---------------------------------------------------------------------------
# Channel type schemas
# ---------------------------------------------------------------------------
def test_all_required_types_in_schema():
for t in ("pushover", "email", "signal", "matrix", "sms_voipms"):
assert t in settings_mod.CHANNEL_TYPE_SCHEMAS
def test_schema_fields_have_required_keys():
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
assert "label" in schema, f"{type_id} missing label"
assert "fields" in schema, f"{type_id} missing fields"
for f in schema["fields"]:
for k in ("key", "label", "type", "required"):
assert k in f, f"{type_id} field missing {k!r}"
def test_secret_fields_use_secret_type():
"""Known secret fields must be typed 'secret' so the UI masks them."""
secret_keys = {"token", "user_key", "api_key", "api_password",
"smtp_password", "access_token"}
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
for f in schema["fields"]:
if f["key"] in secret_keys:
assert f["type"] == "secret", (
f"{type_id}.{f['key']} should be type 'secret'"
)
def test_channel_labels_not_empty():
for type_id, schema in settings_mod.CHANNEL_TYPE_SCHEMAS.items():
assert schema["label"].strip(), f"{type_id} has empty label"
+28 -3
View File
@@ -24,7 +24,7 @@ def test_sections_have_section_mode():
sections = settings_mod.get_settings_sections(CFG)
for s in sections:
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
assert s["section_mode"] in ("form", "yaml")
assert s["section_mode"] in ("form", "yaml", "channels")
def test_sections_have_api_section():
@@ -45,16 +45,41 @@ def test_network_section_has_editable_fields():
def test_yaml_sections_have_correct_mode():
sections = settings_mod.get_settings_sections(CFG)
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
assert "channels" in yaml_sections
assert "channels" not in yaml_sections # now uses "channels" mode
assert "hosts" in yaml_sections
assert "thresholds" in yaml_sections
assert "dns" in yaml_sections
assert yaml_sections["channels"]["api_section"] == "notification_channels"
assert yaml_sections["hosts"]["api_section"] == "hosts"
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
assert yaml_sections["dns"]["api_section"] == "dns"
def test_channels_section_uses_channels_mode():
sections = settings_mod.get_settings_sections(CFG)
ch_sec = next(s for s in sections if s["id"] == "channels")
assert ch_sec["section_mode"] == "channels"
assert ch_sec["api_section"] == "notification_channels"
assert len(ch_sec["channels"]) == 1
ch = ch_sec["channels"][0]
assert ch["name"] == "pushover_ops"
assert ch["type"] == "pushover"
assert "owner" in ch
assert "private" in ch
def test_channel_type_schemas_exported():
assert hasattr(settings_mod, "CHANNEL_TYPE_SCHEMAS")
for required_type in ("pushover", "email", "signal", "matrix", "sms_voipms"):
assert required_type in settings_mod.CHANNEL_TYPE_SCHEMAS
schema = settings_mod.CHANNEL_TYPE_SCHEMAS[required_type]
assert "label" in schema
assert "fields" in schema
for f in schema["fields"]:
assert "key" in f
assert "type" in f
assert "required" in f
def test_oauth_section_exists():
sections = settings_mod.get_settings_sections(CFG)
oauth = next((s for s in sections if s["id"] == "oauth"), None)