mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Security hardening (maziggy/bambuddy-security #31)
This commit is contained in:
@@ -12,6 +12,7 @@ from sqlalchemy import delete, desc, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import (
|
||||
RequestPrinterScope,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
ScopedCaller,
|
||||
probe_permissions_if_auth_enabled,
|
||||
@@ -19,7 +20,7 @@ from backend.app.core.auth import (
|
||||
)
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.core.printer_scope import ensure_printers_exist
|
||||
from backend.app.core.printer_scope import PrinterScope, ensure_printers_exist
|
||||
from backend.app.models.notification import NotificationLog, NotificationProvider
|
||||
from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
|
||||
from backend.app.models.user import User
|
||||
@@ -193,14 +194,58 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _provider_in_scope(provider: NotificationProvider, printer_scope: PrinterScope) -> bool:
|
||||
"""Whether a caller limited to some printers may see and manage ``provider``.
|
||||
|
||||
Only one bound to one of their printers: a provider for all printers (or
|
||||
another printer) delivers events of printers outside their groups.
|
||||
"""
|
||||
if printer_scope.is_unrestricted:
|
||||
return True
|
||||
return provider.printer_id is not None and printer_scope.allows(provider.printer_id)
|
||||
|
||||
|
||||
def _log_scope_clause(printer_scope: PrinterScope):
|
||||
"""The log entries a caller limited to some printers sees (None: all).
|
||||
|
||||
Those of their printers, and those with no printer sent by a provider they
|
||||
can see: an entry with no printer belongs to whoever owns its provider.
|
||||
"""
|
||||
if printer_scope.printer_ids is None:
|
||||
return None
|
||||
own_providers = select(NotificationProvider.id).where(
|
||||
NotificationProvider.printer_id.in_(printer_scope.printer_ids)
|
||||
)
|
||||
return NotificationLog.printer_id.in_(printer_scope.printer_ids) | (
|
||||
NotificationLog.printer_id.is_(None) & NotificationLog.provider_id.in_(own_providers)
|
||||
)
|
||||
|
||||
|
||||
async def _load_provider(db: AsyncSession, provider_id: int, printer_scope: PrinterScope) -> NotificationProvider:
|
||||
provider = await db.get(NotificationProvider, provider_id)
|
||||
if provider is None or not _provider_in_scope(provider, printer_scope):
|
||||
raise HTTPException(status_code=404, detail="Notification provider not found")
|
||||
return provider
|
||||
|
||||
|
||||
def _ensure_target_in_scope(printer_id: int | None, printer_scope: PrinterScope) -> None:
|
||||
"""A caller limited to some printers binds a provider to one of them, never to all."""
|
||||
if printer_scope.is_unrestricted:
|
||||
return
|
||||
if printer_id is None:
|
||||
raise HTTPException(status_code=400, detail="Choose one of your printers for this provider")
|
||||
printer_scope.ensure(printer_id)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[NotificationProviderResponse])
|
||||
async def list_notification_providers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""List all notification providers."""
|
||||
result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
|
||||
providers = result.scalars().all()
|
||||
providers = [p for p in result.scalars().all() if _provider_in_scope(p, printer_scope)]
|
||||
|
||||
return [_provider_to_dict(provider) for provider in providers]
|
||||
|
||||
@@ -210,8 +255,10 @@ async def create_notification_provider(
|
||||
provider_data: NotificationProviderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Create a new notification provider."""
|
||||
_ensure_target_in_scope(provider_data.printer_id, printer_scope)
|
||||
await ensure_printers_exist(db, [provider_data.printer_id])
|
||||
try:
|
||||
reject_placeholder(provider_data.config)
|
||||
@@ -308,6 +355,7 @@ async def test_notification_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
|
||||
can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.NOTIFICATIONS_UPDATE)),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Test notification configuration before saving.
|
||||
|
||||
@@ -321,9 +369,7 @@ async def test_notification_config(
|
||||
else:
|
||||
if not can_update:
|
||||
raise HTTPException(status_code=403, detail="Testing a saved provider requires notifications:update")
|
||||
provider = await db.get(NotificationProvider, test_request.provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail="Notification provider not found")
|
||||
provider = await _load_provider(db, test_request.provider_id, printer_scope)
|
||||
config = merge_config(
|
||||
provider.provider_type, _stored_config(provider), test_request.provider_type.value, config
|
||||
)
|
||||
@@ -340,10 +386,11 @@ async def test_notification_config(
|
||||
async def test_all_notification_providers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Send a test notification to all enabled providers."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
|
||||
providers = result.scalars().all()
|
||||
providers = [p for p in result.scalars().all() if _provider_in_scope(p, printer_scope)]
|
||||
|
||||
if not providers:
|
||||
return {"tested": 0, "success": 0, "failed": 0, "results": []}
|
||||
@@ -402,9 +449,12 @@ async def get_notification_logs(
|
||||
days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Get notification logs with optional filters."""
|
||||
query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
|
||||
if (scope_clause := _log_scope_clause(printer_scope)) is not None:
|
||||
query = query.where(scope_clause)
|
||||
|
||||
# Apply filters
|
||||
if provider_id is not None:
|
||||
@@ -434,6 +484,13 @@ async def get_notification_logs(
|
||||
providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
|
||||
|
||||
provider = providers_cache[log.provider_id]
|
||||
# A provider the caller can't see is not named, as it isn't listed, and
|
||||
# neither is its error, which names its kind and destination.
|
||||
hidden = not printer_scope.is_unrestricted and (
|
||||
provider is None or not _provider_in_scope(provider, printer_scope)
|
||||
)
|
||||
if hidden:
|
||||
provider = None
|
||||
response.append(
|
||||
NotificationLogResponse(
|
||||
id=log.id,
|
||||
@@ -444,7 +501,7 @@ async def get_notification_logs(
|
||||
title=log.title,
|
||||
message=log.message,
|
||||
success=log.success,
|
||||
error_message=log.error_message,
|
||||
error_message=None if hidden else log.error_message,
|
||||
printer_id=log.printer_id,
|
||||
printer_name=log.printer_name,
|
||||
created_at=log.created_at,
|
||||
@@ -459,37 +516,44 @@ async def get_notification_log_stats(
|
||||
days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Get notification log statistics."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
# Counted over the entries the log list shows this caller.
|
||||
window = [NotificationLog.created_at >= cutoff]
|
||||
if (scope_clause := _log_scope_clause(printer_scope)) is not None:
|
||||
window.append(scope_clause)
|
||||
|
||||
# Total counts
|
||||
total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
|
||||
total_result = await db.execute(select(func.count(NotificationLog.id)).where(*window))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
success_result = await db.execute(
|
||||
select(func.count(NotificationLog.id)).where(
|
||||
NotificationLog.created_at >= cutoff, NotificationLog.success.is_(True)
|
||||
)
|
||||
select(func.count(NotificationLog.id)).where(*window, NotificationLog.success.is_(True))
|
||||
)
|
||||
success_count = success_result.scalar() or 0
|
||||
|
||||
# By event type
|
||||
event_result = await db.execute(
|
||||
select(NotificationLog.event_type, func.count(NotificationLog.id))
|
||||
.where(NotificationLog.created_at >= cutoff)
|
||||
.where(*window)
|
||||
.group_by(NotificationLog.event_type)
|
||||
)
|
||||
by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
|
||||
|
||||
# By provider (need to join to get name)
|
||||
provider_result = await db.execute(
|
||||
select(NotificationProvider.name, func.count(NotificationLog.id))
|
||||
select(NotificationProvider, func.count(NotificationLog.id))
|
||||
.join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
|
||||
.where(NotificationLog.created_at >= cutoff)
|
||||
.group_by(NotificationProvider.name)
|
||||
.where(*window)
|
||||
.group_by(NotificationProvider.id)
|
||||
)
|
||||
by_provider = {row[0]: row[1] for row in provider_result.fetchall()}
|
||||
# Providers the caller can't see are counted together, not by name.
|
||||
by_provider: dict[str, int] = {}
|
||||
for provider, count in provider_result.all():
|
||||
name = provider.name if _provider_in_scope(provider, printer_scope) else "Other"
|
||||
by_provider[name] = by_provider.get(name, 0) + count
|
||||
|
||||
return NotificationLogStats(
|
||||
total=total,
|
||||
@@ -505,11 +569,16 @@ async def clear_notification_logs(
|
||||
older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Clear old notification logs."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
||||
|
||||
result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
|
||||
statement = delete(NotificationLog).where(NotificationLog.created_at < cutoff)
|
||||
# A caller limited to some printers clears only those printers' entries.
|
||||
if (scope_clause := printer_scope.where_strict(NotificationLog.printer_id)) is not None:
|
||||
statement = statement.where(scope_clause)
|
||||
result = await db.execute(statement)
|
||||
await db.commit()
|
||||
|
||||
deleted_count = result.rowcount
|
||||
@@ -581,14 +650,21 @@ async def send_app_message(
|
||||
data: AppMessage,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
caller: ScopedCaller = Depends(require_notification_send()),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Send a message through every enabled channel that has "Messages from
|
||||
connected apps" on. For other applications, e.g. Bambuddy Orders; an API
|
||||
key needs the "Send notifications" permission."""
|
||||
key needs the "Send notifications" permission. A caller limited to some
|
||||
printers reaches only the channels of those printers."""
|
||||
key, sender = _app_sender(caller)
|
||||
_check_app_message_rate(key)
|
||||
channels = await notification_service.on_app_message(
|
||||
db, sender=sender, title=data.title, message=data.message, url=data.url
|
||||
db,
|
||||
sender=sender,
|
||||
title=data.title,
|
||||
message=data.message,
|
||||
url=data.url,
|
||||
allowed=lambda provider: _provider_in_scope(provider, printer_scope),
|
||||
)
|
||||
return AppMessageResult(channels=channels)
|
||||
|
||||
@@ -597,6 +673,7 @@ async def send_app_message(
|
||||
async def app_message_channels(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: ScopedCaller = Depends(require_notification_send()),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""The enabled channels that deliver app messages: names and types only,
|
||||
so an app can tell its user where its messages will arrive."""
|
||||
@@ -605,7 +682,11 @@ async def app_message_channels(
|
||||
.where(NotificationProvider.enabled.is_(True), NotificationProvider.on_app_message.is_(True))
|
||||
.order_by(NotificationProvider.name)
|
||||
)
|
||||
return [AppMessageChannel(name=p.name, provider_type=p.provider_type) for p in rows.scalars()]
|
||||
return [
|
||||
AppMessageChannel(name=p.name, provider_type=p.provider_type)
|
||||
for p in rows.scalars()
|
||||
if _provider_in_scope(p, printer_scope)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{provider_id}", response_model=NotificationProviderResponse)
|
||||
@@ -613,13 +694,10 @@ async def get_notification_provider(
|
||||
provider_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Get a specific notification provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Notification provider not found")
|
||||
provider = await _load_provider(db, provider_id, printer_scope)
|
||||
|
||||
return _provider_to_dict(provider)
|
||||
|
||||
@@ -630,19 +708,18 @@ async def update_notification_provider(
|
||||
update_data: NotificationProviderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Update a notification provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Notification provider not found")
|
||||
provider = await _load_provider(db, provider_id, printer_scope)
|
||||
|
||||
old_config = _stored_config(provider)
|
||||
old_type = provider.provider_type
|
||||
|
||||
# Update only provided fields
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
if "printer_id" in update_dict:
|
||||
_ensure_target_in_scope(update_dict["printer_id"], printer_scope)
|
||||
await ensure_printers_exist(db, [update_dict.get("printer_id")])
|
||||
|
||||
effective_type = update_dict.get("provider_type") or provider.provider_type
|
||||
@@ -720,13 +797,10 @@ async def delete_notification_provider(
|
||||
provider_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Delete a notification provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Notification provider not found")
|
||||
provider = await _load_provider(db, provider_id, printer_scope)
|
||||
|
||||
name = provider.name
|
||||
notify = await delete_provider(db, provider)
|
||||
@@ -743,13 +817,10 @@ async def test_notification_provider(
|
||||
provider_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
|
||||
printer_scope: PrinterScope = RequestPrinterScope,
|
||||
):
|
||||
"""Send a test notification using an existing provider."""
|
||||
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
||||
provider = result.scalar_one_or_none()
|
||||
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Notification provider not found")
|
||||
provider = await _load_provider(db, provider_id, printer_scope)
|
||||
|
||||
config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
|
||||
success, message = await notification_service.send_test_notification(
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
@@ -1712,6 +1713,7 @@ class NotificationService:
|
||||
title: str,
|
||||
message: str,
|
||||
url: str | None = None,
|
||||
allowed: Callable[[NotificationProvider], bool] | None = None,
|
||||
) -> int:
|
||||
"""A message another application sends through Bambuddy.
|
||||
|
||||
@@ -1719,9 +1721,12 @@ class NotificationService:
|
||||
through the same path as Bambuddy's own events: quiet hours, the daily
|
||||
digest and the log (whose event type names the sender). The text is
|
||||
the app's own; a link, when given, is appended so every channel type
|
||||
carries it. Returns how many channels it was handed to.
|
||||
carries it. Returns how many channels it was handed to. ``allowed``
|
||||
narrows the channels to those the sender may reach.
|
||||
"""
|
||||
providers = await self._get_providers_for_event(db, "on_app_message")
|
||||
if allowed is not None:
|
||||
providers = [provider for provider in providers if allowed(provider)]
|
||||
if not providers:
|
||||
return 0
|
||||
body = f"{message}\n{url}" if url else message
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Notification providers and logs keep to the caller's printers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.notification import NotificationLog
|
||||
from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth, _group, _user
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
PERMS = [
|
||||
"printers:read",
|
||||
"notifications:read",
|
||||
"notifications:create",
|
||||
"notifications:update",
|
||||
"notifications:delete",
|
||||
]
|
||||
|
||||
|
||||
def _provider(name: str, printer_id: int | None) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"provider_type": "callmebot",
|
||||
"enabled": True,
|
||||
"config": {"phone_number": "+1234567890", "api_key": "test-api-key"},
|
||||
"printer_id": printer_id,
|
||||
}
|
||||
|
||||
|
||||
async def _setup(client: AsyncClient, printer_factory):
|
||||
a, b = await printer_factory(name="A"), await printer_factory(name="B")
|
||||
admin = await _admin_token(client)
|
||||
perms = await _group(client, admin, "perms", permissions=PERMS)
|
||||
team = await _group(client, admin, "team", printer_ids=[a.id])
|
||||
member = _auth((await _user(client, admin, "member", [perms, team]))[0])
|
||||
ids = {}
|
||||
for name, printer in (("ours", a.id), ("theirs", b.id), ("everyone", None)):
|
||||
created = await client.post("/api/v1/notifications/", headers=_auth(admin), json=_provider(name, printer))
|
||||
assert created.status_code == 200, created.text
|
||||
ids[name] = created.json()["id"]
|
||||
return a, b, member, ids
|
||||
|
||||
|
||||
async def test_a_member_sees_and_manages_only_providers_of_their_printers(async_client: AsyncClient, printer_factory):
|
||||
a, b, member, ids = await _setup(async_client, printer_factory)
|
||||
|
||||
listed = await async_client.get("/api/v1/notifications/", headers=member)
|
||||
|
||||
assert [p["name"] for p in listed.json()] == ["ours"]
|
||||
for name in ("theirs", "everyone"):
|
||||
pid = ids[name]
|
||||
assert (await async_client.get(f"/api/v1/notifications/{pid}", headers=member)).status_code == 404
|
||||
patched = await async_client.patch(f"/api/v1/notifications/{pid}", headers=member, json={"name": "mine now"})
|
||||
assert patched.status_code == 404
|
||||
assert (await async_client.post(f"/api/v1/notifications/{pid}/test", headers=member)).status_code == 404
|
||||
assert (await async_client.delete(f"/api/v1/notifications/{pid}", headers=member)).status_code == 404
|
||||
assert (await async_client.get(f"/api/v1/notifications/{ids['ours']}", headers=member)).status_code == 200
|
||||
|
||||
|
||||
async def test_a_member_binds_providers_to_their_printers_only(async_client: AsyncClient, printer_factory):
|
||||
a, b, member, ids = await _setup(async_client, printer_factory)
|
||||
|
||||
to_other = await async_client.post("/api/v1/notifications/", headers=member, json=_provider("x", b.id))
|
||||
to_all = await async_client.post("/api/v1/notifications/", headers=member, json=_provider("y", None))
|
||||
to_own = await async_client.post("/api/v1/notifications/", headers=member, json=_provider("z", a.id))
|
||||
retarget_other = await async_client.patch(
|
||||
f"/api/v1/notifications/{ids['ours']}", headers=member, json={"printer_id": b.id}
|
||||
)
|
||||
retarget_all = await async_client.patch(
|
||||
f"/api/v1/notifications/{ids['ours']}", headers=member, json={"printer_id": None}
|
||||
)
|
||||
|
||||
assert (to_other.status_code, to_all.status_code, to_own.status_code) == (404, 400, 200)
|
||||
assert (retarget_other.status_code, retarget_all.status_code) == (404, 400)
|
||||
|
||||
|
||||
async def test_logs_and_their_counts_keep_to_the_team_printers(async_client: AsyncClient, db_session, printer_factory):
|
||||
a, b, member, ids = await _setup(async_client, printer_factory)
|
||||
a_id, b_id = a.id, b.id
|
||||
old = datetime.utcnow() - timedelta(days=40)
|
||||
for printer, when in ((a_id, None), (b_id, None), (None, None), (a_id, old), (b_id, old), (None, old)):
|
||||
row = NotificationLog(
|
||||
provider_id=ids["ours"], event_type="print_start", title="t", message="m", printer_id=printer
|
||||
)
|
||||
if when is not None:
|
||||
row.created_at = when
|
||||
db_session.add(row)
|
||||
await db_session.commit()
|
||||
|
||||
listed = await async_client.get("/api/v1/notifications/logs", headers=member)
|
||||
stats = await async_client.get("/api/v1/notifications/logs/stats", headers=member)
|
||||
cleared = await async_client.delete("/api/v1/notifications/logs", headers=member, params={"older_than_days": 30})
|
||||
|
||||
assert sorted((row["printer_id"] or 0) for row in listed.json()) == [0, a_id]
|
||||
assert stats.json()["total"] == 2
|
||||
assert cleared.json()["deleted"] == 1
|
||||
db_session.expire_all()
|
||||
left = sorted((pid or 0) for (pid,) in (await db_session.execute(select(NotificationLog.printer_id))).all())
|
||||
assert left == sorted([a_id, b_id, 0, b_id, 0])
|
||||
|
||||
|
||||
async def test_testing_a_stored_provider_answers_a_hidden_one_like_a_missing_one(
|
||||
async_client: AsyncClient, printer_factory
|
||||
):
|
||||
a, b, member, ids = await _setup(async_client, printer_factory)
|
||||
body = {"provider_type": "callmebot", "config": {"phone_number": "+1234567890", "api_key": "********"}}
|
||||
|
||||
hidden = await async_client.post(
|
||||
"/api/v1/notifications/test-config", headers=member, json={**body, "provider_id": ids["theirs"]}
|
||||
)
|
||||
missing = await async_client.post(
|
||||
"/api/v1/notifications/test-config", headers=member, json={**body, "provider_id": 999_999}
|
||||
)
|
||||
|
||||
assert (
|
||||
(hidden.status_code, hidden.json())
|
||||
== (missing.status_code, missing.json())
|
||||
== (
|
||||
404,
|
||||
{"detail": "Notification provider not found"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_app_messages_reach_only_the_team_printers_channels(async_client: AsyncClient, printer_factory):
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
a, b = await printer_factory(name="A"), await printer_factory(name="B")
|
||||
admin = await _admin_token(async_client)
|
||||
perms = await _group(async_client, admin, "perms", permissions=PERMS)
|
||||
team = await _group(async_client, admin, "team", printer_ids=[a.id])
|
||||
member = _auth((await _user(async_client, admin, "member", [perms, team]))[0])
|
||||
for name, printer in (("ours", a.id), ("theirs", b.id), ("everyone", None)):
|
||||
created = await async_client.post(
|
||||
"/api/v1/notifications/", headers=_auth(admin), json={**_provider(name, printer), "on_app_message": True}
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
|
||||
channels = await async_client.get("/api/v1/notifications/app-message/channels", headers=member)
|
||||
with patch(
|
||||
"backend.app.services.notification_service.NotificationService._send_to_providers", new=AsyncMock()
|
||||
) as send:
|
||||
sent = await async_client.post(
|
||||
"/api/v1/notifications/app-message", headers=member, json={"title": "t", "message": "m"}
|
||||
)
|
||||
|
||||
assert [c["name"] for c in channels.json()] == ["ours"]
|
||||
assert sent.json()["channels"] == 1
|
||||
assert [p.name for p in send.await_args.args[0]] == ["ours"]
|
||||
|
||||
|
||||
async def test_the_log_does_not_name_providers_the_caller_cannot_see(
|
||||
async_client: AsyncClient, db_session, printer_factory
|
||||
):
|
||||
a, b, member, ids = await _setup(async_client, printer_factory)
|
||||
a_id = a.id
|
||||
for provider in ("ours", "everyone"):
|
||||
db_session.add(
|
||||
NotificationLog(
|
||||
provider_id=ids[provider],
|
||||
event_type="print_start",
|
||||
title="t",
|
||||
message="m",
|
||||
printer_id=a_id,
|
||||
success=False,
|
||||
error_message=f"failed for {provider}",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
listed = await async_client.get("/api/v1/notifications/logs", headers=member)
|
||||
stats = await async_client.get("/api/v1/notifications/logs/stats", headers=member)
|
||||
|
||||
rows = sorted(listed.json(), key=lambda row: str(row["provider_name"]))
|
||||
assert [(row["provider_name"], row["error_message"]) for row in rows] == [
|
||||
(None, None),
|
||||
("ours", "failed for ours"),
|
||||
]
|
||||
assert stats.json()["by_provider"] == {"ours": 1, "Other": 1}
|
||||
|
||||
|
||||
async def test_log_entries_without_a_printer_stay_with_their_provider(
|
||||
async_client: AsyncClient, db_session, printer_factory
|
||||
):
|
||||
a, b, member, ids = await _setup(async_client, printer_factory)
|
||||
for provider in ("ours", "everyone"):
|
||||
db_session.add(
|
||||
NotificationLog(
|
||||
provider_id=ids[provider], event_type="app:key", title=provider, message="m", printer_id=None
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
listed = await async_client.get("/api/v1/notifications/logs", headers=member)
|
||||
stats = await async_client.get("/api/v1/notifications/logs/stats", headers=member)
|
||||
|
||||
assert [row["title"] for row in listed.json()] == ["ours"]
|
||||
assert stats.json()["total"] == 1
|
||||
|
||||
|
||||
async def test_a_log_entry_whose_provider_is_gone_shows_no_error(
|
||||
async_client: AsyncClient, db_session, printer_factory
|
||||
):
|
||||
if db_session.bind.dialect.name != "sqlite":
|
||||
pytest.skip("only SQLite can keep such an entry")
|
||||
a, b, member, ids = await _setup(async_client, printer_factory)
|
||||
db_session.add(
|
||||
NotificationLog(
|
||||
provider_id=999_999,
|
||||
event_type="print_start",
|
||||
title="t",
|
||||
message="m",
|
||||
printer_id=a.id,
|
||||
success=False,
|
||||
error_message="failed",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
listed = await async_client.get("/api/v1/notifications/logs", headers=member)
|
||||
|
||||
assert [(row["provider_name"], row["error_message"]) for row in listed.json()] == [(None, None)]
|
||||
Reference in New Issue
Block a user