Files
PTah 3765d4d476 feat: RDP flap auto-disconnect and hide break button after reconnect (0.4.10)
Add optional auto logoff of stuck sessions on RDG flap and direct RDP failure. Hide the RDS break button on old flap 302 when the user later reconnects or the workstation session is closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:50:03 +10:00

340 lines
9.3 KiB
Python

"""Tests for RDG 302→303 session flap rule."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from app.config import get_settings
from app.models import Problem
from app.services.ingest import ingest_event
from app.services.problems import maybe_create_problem
from app.services.problem_rules import RULE_RDG_SESSION_FLAP
from app.services.rdg_session_flap import (
evaluate_rdg_session_flap,
event_has_rdg_flap,
find_rdg_success_before_end,
resolve_rdg_flap_summary,
resolve_rdg_qwinsta_enabled,
)
from app.services.event_summary import event_to_summary
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
@pytest.fixture
def rdg_settings(monkeypatch):
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1")
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10")
monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30")
get_settings.cache_clear()
yield
get_settings.cache_clear()
def test_rdg_flap_detects_302_then_303(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\test.user"
details = {"user": user, "internal_ip": "192.168.163.32"}
_ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="info",
title="RD Gateway event 302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(seconds=4),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="RD Gateway event 303",
summary="303",
details=details,
)
match = evaluate_rdg_session_flap(db_session, end)
assert match is not None
assert match.rule_id == RULE_RDG_SESSION_FLAP
assert event_has_rdg_flap(end) is True
problem, created = maybe_create_problem(db_session, end)
assert created is True
assert problem.rule_id == RULE_RDG_SESSION_FLAP
def test_rdg_flap_ignores_gap_over_10_sec(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\slow.user"
details = {"user": user}
_ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(seconds=15),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
assert find_rdg_success_before_end(db_session, end) is None
assert evaluate_rdg_session_flap(db_session, end) is None
def test_rdg_flap_dedup_within_30_sec(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\dup.user"
details = {"user": user}
def pair(at: datetime):
_ingest(
db_session,
at,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
return _ingest(
db_session,
at + timedelta(seconds=5),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
end1 = pair(t0)
maybe_create_problem(db_session, end1)
db_session.flush()
end2 = pair(t0 + timedelta(seconds=10))
maybe_create_problem(db_session, end2)
db_session.flush()
from sqlalchemy import func, select
open_count = db_session.scalar(
select(func.count()).select_from(Problem).where(
Problem.rule_id == RULE_RDG_SESSION_FLAP,
Problem.status == "open",
)
)
assert open_count == 1
def test_resolve_rdg_flap_summary_for_302_and_303(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\pair.user"
details = {"user": user, "internal_ip": "192.168.163.48"}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="warning",
title="RD Gateway event 302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(seconds=5),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="RD Gateway event 303",
summary="303",
details=details,
)
evaluate_rdg_session_flap(db_session, end)
db_session.flush()
end_flap, end_pair, end_qwinsta = resolve_rdg_flap_summary(db_session, end)
start_flap, start_pair, start_qwinsta = resolve_rdg_flap_summary(db_session, start)
assert end_flap is True
assert end_pair == start.id
assert end_qwinsta == start.id
assert start_flap is True
assert start_pair == end.id
assert start_qwinsta == start.id
end_summary = event_to_summary(end, db_session)
start_summary = event_to_summary(start, db_session)
assert end_summary.rdg_flap is True
assert start_summary.rdg_flap is True
assert start_summary.rdg_flap_qwinsta_event_id == start.id
assert start_summary.rdg_qwinsta_enabled is True
assert end_summary.rdg_qwinsta_enabled is False
def test_rdg_qwinsta_disabled_after_normal_session_end(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\normal.user"
details = {"user": user, "internal_ip": "192.168.163.49"}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(minutes=20),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
assert resolve_rdg_qwinsta_enabled(db_session, start) is False
assert resolve_rdg_qwinsta_enabled(db_session, end) is False
start_summary = event_to_summary(start, db_session)
end_summary = event_to_summary(end, db_session)
assert start_summary.rdg_qwinsta_enabled is False
assert end_summary.rdg_qwinsta_enabled is False
def test_rdg_qwinsta_enabled_while_session_open(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
details = {"user": r"B26\active.user", "internal_ip": "192.168.163.50"}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
assert resolve_rdg_qwinsta_enabled(db_session, start) is True
assert event_to_summary(start, db_session).rdg_qwinsta_enabled is True
def test_rdg_qwinsta_disabled_on_flap_302_after_later_success(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = r"B26\m.semenova"
details = {"user": user, "internal_ip": "192.168.164.45"}
flap_start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="warning",
title="302",
summary="302",
details=details,
)
flap_end = _ingest(
db_session,
t0 + timedelta(seconds=5),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
evaluate_rdg_session_flap(db_session, flap_end)
db_session.flush()
assert resolve_rdg_qwinsta_enabled(db_session, flap_start) is True
_ingest(
db_session,
t0 + timedelta(minutes=2, seconds=44),
type="rdg.connection.success",
category="auth",
severity="warning",
title="302",
summary="302",
details=details,
)
assert resolve_rdg_qwinsta_enabled(db_session, flap_start) is False
assert event_to_summary(flap_start, db_session).rdg_qwinsta_enabled is False
def test_rdg_qwinsta_stays_enabled_when_unrelated_303_without_internal_ip(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\khodasevich"
details_302 = {"user": user, "internal_ip": "192.168.160.209"}
details_303_other = {"user": user}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="warning",
title="302",
summary="302",
details=details_302,
)
_ingest(
db_session,
t0 + timedelta(minutes=5),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details_303_other,
)
assert resolve_rdg_qwinsta_enabled(db_session, start) is True
assert event_to_summary(start, db_session).rdg_qwinsta_enabled is True