Files
gh-christianlouis-dmarq/backend/app/tests/test_workspace_audit.py
T
2026-05-23 19:38:27 +02:00

235 lines
8.5 KiB
Python

import json
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.workspace_access import WorkspaceAuditLog
from app.services.workspace_access import (
PERMISSION_WORKSPACE_ADMIN,
ROLE_DOMAIN_ADMIN,
ROLE_WORKSPACE_OWNER,
require_workspace_permission,
role_for_auth_context,
)
from app.services.workspace_audit import (
actor_from_auth,
audit_log_to_dict,
list_workspace_audit_logs,
record_workspace_audit_log,
sanitize_audit_details,
)
from app.services.workspaces import get_or_create_default_workspace
def test_workspace_roles_endpoint_lists_permissions(authed_client: TestClient):
"""Operators can discover the workspace RBAC vocabulary."""
response = authed_client.get("/api/v1/audit/roles")
assert response.status_code == 200
roles = {item["role"]: set(item["permissions"]) for item in response.json()["roles"]}
assert ROLE_WORKSPACE_OWNER in roles
assert ROLE_DOMAIN_ADMIN in roles
assert "workspace:admin" in roles[ROLE_WORKSPACE_OWNER]
assert "mail_sources:write" in roles[ROLE_DOMAIN_ADMIN]
def test_audit_details_sanitize_secret_like_fields():
"""Audit helper redacts nested secret-shaped fields."""
details = sanitize_audit_details(
{
"name": "mailbox",
"password": "super-secret",
"nested": {"refresh_token": "token-secret"},
}
)
assert details["name"] == "mailbox"
assert details["password"] == "[redacted]"
assert details["nested"]["refresh_token"] == "[redacted]"
assert "super-secret" not in json.dumps(details)
assert "token-secret" not in json.dumps(details)
def test_workspace_permission_denial_and_actor_variants(db_session: Session):
"""RBAC and audit helpers cover fallback actors and denial paths."""
workspace = get_or_create_default_workspace(db_session)
assert role_for_auth_context({"auth_type": "unexpected"}) == "auditor"
try:
require_workspace_permission({"auth_type": "unexpected"}, PERMISSION_WORKSPACE_ADMIN)
except Exception as exc: # pylint: disable=broad-exception-caught
assert getattr(exc, "status_code", None) == 403
else:
raise AssertionError("permission denial was not raised")
assert (
actor_from_auth({"auth_type": "jwt", "payload": {"sub": "user-123"}})["actor_id"]
== "user-123"
)
assert actor_from_auth({"auth_type": "api_token", "token_id": 42})["actor_id"] == "42"
assert sanitize_audit_details({"object": object()})["object"].startswith("<object object")
row = record_workspace_audit_log(
db_session,
workspace=workspace,
action="workspace.test",
entity_type="workspace",
entity_id=workspace.id,
details={"client_secret": "hidden"},
auth_context={"auth_type": "jwt", "payload": {"sub": "user-123"}},
)
db_session.commit()
db_session.refresh(row)
assert audit_log_to_dict(row)["details"]["client_secret"] == "[redacted]"
row.details = "{not-json"
assert audit_log_to_dict(row)["details"] == {}
filtered = list_workspace_audit_logs(
db_session,
workspace=workspace,
action="workspace.test",
entity_type="workspace",
)
assert filtered[0]["action"] == "workspace.test"
def test_mail_source_changes_create_workspace_audit_without_secret_values(
authed_client: TestClient,
db_session: Session,
):
"""Mail source create/update actions are auditable without leaking credentials."""
created = authed_client.post(
"/api/v1/mail-sources",
json={
"name": "DMARC Inbox",
"method": "IMAP",
"server": "imap.example.com",
"username": "reports@example.com",
"password": "super-secret-password",
},
)
assert created.status_code == 201
source_id = created.json()["id"]
updated = authed_client.put(
f"/api/v1/mail-sources/{source_id}",
json={"password": "new-secret-password", "folder": "Reports"},
)
assert updated.status_code == 200
toggled = authed_client.post(f"/api/v1/mail-sources/{source_id}/toggle")
assert toggled.status_code == 200
deleted = authed_client.delete(f"/api/v1/mail-sources/{source_id}")
assert deleted.status_code == 204
rows = (
db_session.query(WorkspaceAuditLog)
.filter(WorkspaceAuditLog.entity_type == "mail_source")
.order_by(WorkspaceAuditLog.id)
.all()
)
assert [row.action for row in rows] == [
"mail_source.created",
"mail_source.updated",
"mail_source.toggled",
"mail_source.deleted",
]
serialized = "\n".join(row.details or "" for row in rows)
assert "super-secret-password" not in serialized
assert "new-secret-password" not in serialized
assert "changed_fields" in serialized
def test_notification_setting_audit_is_workspace_scoped_and_redacted(
authed_client: TestClient,
):
"""Notification setting changes appear in workspace audit logs with redacted secrets."""
response = authed_client.put(
"/api/v1/settings/notifications.apprise_urls",
json={"value": "mailto://user:password@example.com"},
headers={"x-forwarded-for": "203.0.113.5, 10.0.0.1"},
)
assert response.status_code == 200
audit = authed_client.get("/api/v1/audit/logs?entity_type=setting")
assert audit.status_code == 200
events = audit.json()["audit"]
assert events[0]["action"] == "setting.changed"
assert events[0]["ip_address"] == "203.0.113.5"
assert events[0]["details"]["new_value"] == "[redacted]"
assert "password@example.com" not in str(events)
def test_api_token_create_and_revoke_are_audited_without_raw_token(
authed_client: TestClient,
):
"""API token lifecycle records expose metadata but not raw secrets."""
created = authed_client.post(
"/api/v1/api-tokens",
json={"name": "SIEM exporter", "scopes": ["reports:read"]},
)
assert created.status_code == 201
body = created.json()
token_id = body["metadata"]["id"]
raw_token = body["token"]
revoked = authed_client.delete(f"/api/v1/api-tokens/{token_id}")
assert revoked.status_code == 200
audit = authed_client.get("/api/v1/audit/logs?entity_type=api_token")
assert audit.status_code == 200
actions = [item["action"] for item in audit.json()["audit"]]
assert actions[:2] == ["api_token.revoked", "api_token.created"]
assert raw_token not in str(audit.json())
def test_webhook_changes_are_audited_without_signing_secret(authed_client: TestClient):
"""Webhook lifecycle changes write sanitized audit events."""
created = authed_client.post(
"/api/v1/webhooks",
json={
"name": "SIEM receiver",
"url": "https://example.com/dmarq",
"secret": "very-secret-webhook-signing-value",
},
)
assert created.status_code == 200
endpoint_id = created.json()["id"]
updated = authed_client.put(
f"/api/v1/webhooks/{endpoint_id}",
json={"name": "Updated receiver", "secret": "another-secret-webhook-value"},
)
assert updated.status_code == 200
disabled = authed_client.delete(f"/api/v1/webhooks/{endpoint_id}")
assert disabled.status_code == 200
audit = authed_client.get("/api/v1/audit/logs?entity_type=webhook_endpoint")
assert audit.status_code == 200
actions = [event["action"] for event in audit.json()["audit"]]
assert actions[:3] == ["webhook.disabled", "webhook.updated", "webhook.created"]
serialized = str(audit.json())
assert "very-secret-webhook-signing-value" not in serialized
assert "another-secret-webhook-value" not in serialized
def test_manual_selector_changes_are_audited(authed_client: TestClient):
"""Manual DKIM selector changes create audit entries."""
created = authed_client.post(
"/api/v1/domains/domains",
json={"name": "selector-audit.example"},
)
assert created.status_code == 201
added = authed_client.post(
"/api/v1/domains/selector-audit.example/selectors",
json={"selector": "s2026"},
)
assert added.status_code == 201
removed = authed_client.delete("/api/v1/domains/selector-audit.example/selectors/s2026")
assert removed.status_code == 200
audit = authed_client.get("/api/v1/audit/logs?entity_type=domain")
assert audit.status_code == 200
actions = [event["action"] for event in audit.json()["audit"]]
assert actions[:2] == ["domain.selector_removed", "domain.selector_added"]