Files
ssh-jumphost/tests/test_tenants.py
2026-08-21 06:14:10 +02:00

628 lines
26 KiB
Python

"""
Tests fuer Mandantenfaehigkeit (volle Isolation + Mandanten-Admins, siehe
app/tenancy.py, app/db/migrations/0006_tenants.sql) sowie fuer die
CRUD-Vervollstaendigung (Edit/Delete fuer alles, was die Admin-API anlegt,
siehe 0007_crud_extras.sql) und die Mehrfachauswahl bei Rollenvergaben
(role_names: list statt role_name: str).
Deckt ab:
1) Tenant-CRUD ist ausschliesslich Super-Admin-Aktion.
2) Ein Mandanten-Admin sieht/aendert NUR Ressourcen seines eigenen
Mandanten -- Zugriff auf eine fremde Mandanten-ID liefert 404 (nicht 403,
bewusst um Existenz nicht zu verraten, siehe app/tenancy.py).
3) Der Super-Admin (Alt-Verhalten, Mandanten-uebergreifend) bleibt
vollstaendig funktionsfaehig -- keine Regression durch Mandantenfaehigkeit.
4) Mehrfachauswahl bei Rollenvergabe (role_names) funktioniert fuer
Einzel-User UND Benutzergruppen.
5) Update/Delete-Endpunkte fuer User, Hostgruppen, Hosts, SSH-Keys,
Benutzergruppen funktionieren wie vorgesehen (inkl. Anonymisieren statt
Hard-Delete bei vorhandener Audit-Historie, Soft-Delete bei Hosts).
6) GET /admin/hosts/{id} (Grundlage fuer den "Details"-Fix) liefert den
vollstaendigen aktuellen Datensatz inkl. SSH-Key-Zuordnungen.
"""
from __future__ import annotations
import pyotp
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
def _key_pem() -> str:
"""Echtes, unverschluesseltes Schluesselmaterial.
Seit Phase 10 prueft POST/PUT /admin/ssh-keys das Material sofort gegen
asyncssh (damit ein unbrauchbarer Schluessel nicht erst beim ersten
Verbindungsversuch auffaellt). Platzhalter wie "PEM" werden deshalb --
voellig korrekt -- mit HTTP 400 abgelehnt; diese Tests hier pruefen aber
Mandantenisolation und CRUD und brauchen einen gueltigen Schluessel.
"""
return ed25519.Ed25519PrivateKey.generate().private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.OpenSSH,
serialization.NoEncryption(),
).decode()
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False, home_tenant_id=None) -> int:
from app.security.passwords import hash_password
cursor = await conn.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password, home_tenant_id) "
"VALUES (?, ?, ?, 0, ?)",
(username, hash_password(password), int(is_admin), home_tenant_id),
)
await conn.commit()
return cursor.lastrowid
async def _login_full(client, username: str, password: str) -> str:
resp = await client.post("/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200, resp.text
pending = resp.json()["pending_token"]
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
assert resp.status_code == 200, resp.text
provisioning_uri = resp.json()["provisioning_uri"]
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
code = pyotp.TOTP(secret).now()
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
assert resp.status_code == 200, resp.text
return resp.cookies.get("jh_session")
async def _setup_hostgroup_and_host(conn, *, tenant_id=1, group_name="team-a", hostname="srv-a"):
cursor = await conn.execute(
"INSERT INTO host_groups (name, tenant_id) VALUES (?, ?)", (group_name, tenant_id)
)
hg_id = cursor.lastrowid
cursor = await conn.execute(
"INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type) "
"VALUES (?, ?, '10.0.0.9', 'ssh', 22, 'linux')",
(hg_id, hostname),
)
await conn.commit()
return hg_id, cursor.lastrowid
# ---------------------------------------------------------------------------
# 1) Tenant-CRUD: Super-Admin only
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tenant_crud_requires_super_admin(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sa1", "Correct-Horse-Battery-Staple-A1", is_admin=True)
await _login_full(client, "sa1", "Correct-Horse-Battery-Staple-A1")
resp = await client.post("/admin/tenants", json={"name": "Kunde A", "description": "Erster MSP-Kunde"})
assert resp.status_code == 201, resp.text
tenant_id = resp.json()["id"]
resp = await client.get("/admin/tenants")
assert resp.status_code == 200, resp.text
assert any(t["id"] == tenant_id for t in resp.json())
resp = await client.put(f"/admin/tenants/{tenant_id}", json={"description": "Aktualisiert"})
assert resp.status_code == 200, resp.text
assert resp.json()["changed"] is True
# Nicht-Admin darf nicht mal lesen.
client.cookies.clear()
await _create_user(conn, "plain_t", "Correct-Horse-Battery-Staple-A2")
await _login_full(client, "plain_t", "Correct-Horse-Battery-Staple-A2")
resp = await client.get("/admin/tenants")
assert resp.status_code == 403
resp = await client.post("/admin/tenants", json={"name": "sollte nicht klappen"})
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_tenant_admin_cannot_perform_tenant_crud(client):
"""Ein Mandanten-Admin ist is_any_admin=True, aber NICHT is_admin -- Tenant-
CRUD haengt an require_global_admin (is_admin), nicht require_admin_session."""
from app.db import get_db
conn = get_db()
sa_id = await _create_user(conn, "sa2", "Correct-Horse-Battery-Staple-B1", is_admin=True)
await _login_full(client, "sa2", "Correct-Horse-Battery-Staple-B1")
resp = await client.post("/admin/tenants", json={"name": "Kunde B"})
tenant_id = resp.json()["id"]
ta_id = await _create_user(conn, "ta_user", "Correct-Horse-Battery-Staple-B2")
resp = await client.post(f"/admin/tenants/{tenant_id}/admins", json={"user_id": ta_id})
assert resp.status_code == 201, resp.text
client.cookies.clear()
await _login_full(client, "ta_user", "Correct-Horse-Battery-Staple-B2")
resp = await client.get("/admin/tenants")
assert resp.status_code == 403
resp = await client.post("/admin/tenants", json={"name": "sollte nicht klappen"})
assert resp.status_code == 403
resp = await client.post(f"/admin/tenants/{tenant_id}/admins", json={"user_id": sa_id})
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_tenant_delete_blocked_while_resources_exist(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sa3", "Correct-Horse-Battery-Staple-C1", is_admin=True)
await _login_full(client, "sa3", "Correct-Horse-Battery-Staple-C1")
resp = await client.post("/admin/tenants", json={"name": "Kunde C"})
tenant_id = resp.json()["id"]
resp = await client.post(
"/admin/host-groups", json={"name": "c-team", "tenant_id": tenant_id}
)
assert resp.status_code == 201, resp.text
hg_id = resp.json()["id"]
resp = await client.delete(f"/admin/tenants/{tenant_id}")
assert resp.status_code == 409, resp.text
resp = await client.delete(f"/admin/host-groups/{hg_id}")
assert resp.status_code == 200, resp.text
resp = await client.delete(f"/admin/tenants/{tenant_id}")
assert resp.status_code == 200, resp.text
# ---------------------------------------------------------------------------
# 2) Volle Isolation: Mandanten-Admin sieht/aendert nur den eigenen Mandanten
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tenant_admin_isolated_from_other_tenants_hostgroups_and_hosts(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sa4", "Correct-Horse-Battery-Staple-D1", is_admin=True)
await _login_full(client, "sa4", "Correct-Horse-Battery-Staple-D1")
resp = await client.post("/admin/tenants", json={"name": "Kunde D"})
tenant_d = resp.json()["id"]
# Ressourcen im Standard-Mandanten (id=1) UND im neuen Mandanten D anlegen.
resp = await client.post("/admin/host-groups", json={"name": "std-team", "tenant_id": 1})
hg_std = resp.json()["id"]
resp = await client.post("/admin/host-groups", json={"name": "d-team", "tenant_id": tenant_d})
hg_d = resp.json()["id"]
resp = await client.post(
"/admin/hosts",
json={
"host_group_id": hg_d, "hostname": "d-srv1", "address": "10.1.0.1",
"protocol": "ssh", "port": 22, "os_type": "linux",
},
)
assert resp.status_code == 201, resp.text
host_d = resp.json()["id"]
ta_id = await _create_user(conn, "ta_d", "Correct-Horse-Battery-Staple-D2")
resp = await client.post(f"/admin/tenants/{tenant_d}/admins", json={"user_id": ta_id})
assert resp.status_code == 201, resp.text
client.cookies.clear()
await _login_full(client, "ta_d", "Correct-Horse-Battery-Staple-D2")
# Host-Gruppen-Liste: nur der eigene Mandant ist sichtbar.
resp = await client.get("/admin/host-groups")
assert resp.status_code == 200, resp.text
ids = {hg["id"] for hg in resp.json()}
assert hg_d in ids
assert hg_std not in ids
# Direkter Zugriff auf eine fremde Hostgruppen-ID -> 404 (nicht 403).
resp = await client.put(f"/admin/host-groups/{hg_std}", json={"name": "hack"})
assert resp.status_code == 404, resp.text
resp = await client.delete(f"/admin/host-groups/{hg_std}")
assert resp.status_code == 404, resp.text
# Eigener Host ist sichtbar und aenderbar.
resp = await client.get(f"/admin/hosts/{host_d}")
assert resp.status_code == 200, resp.text
assert resp.json()["hostname"] == "d-srv1"
# Ein Host im Standard-Mandanten (existiert nicht -- wird nicht angelegt,
# aber ID 999999 simuliert "fremde/unbekannte ID" -> ebenfalls 404).
resp = await client.get("/admin/hosts/999999")
assert resp.status_code == 404
# Anlegen einer Hostgruppe OHNE tenant_id wird automatisch auf den
# eigenen (einzigen) Mandanten erzwungen, Client-Angaben eines fremden
# Mandanten werden ignoriert/abgelehnt.
resp = await client.post("/admin/host-groups", json={"name": "d-team-2"})
assert resp.status_code == 201, resp.text
resp = await client.get("/admin/host-groups")
new_ids = {hg["id"]: hg["tenant_id"] for hg in resp.json()}
assert new_ids[resp.json()[-1]["id"]] == tenant_d
@pytest.mark.asyncio
async def test_tenant_admin_isolated_from_other_tenants_users_and_ssh_keys(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sa5", "Correct-Horse-Battery-Staple-E1", is_admin=True)
await _login_full(client, "sa5", "Correct-Horse-Battery-Staple-E1")
resp = await client.post("/admin/tenants", json={"name": "Kunde E"})
tenant_e = resp.json()["id"]
ta_id = await _create_user(conn, "ta_e", "Correct-Horse-Battery-Staple-E2")
resp = await client.post(f"/admin/tenants/{tenant_e}/admins", json={"user_id": ta_id})
assert resp.status_code == 201, resp.text
# Ein User im Standard-Mandanten (home_tenant_id=1), unsichtbar fuer ta_e.
std_user_id = await _create_user(conn, "std_user", "Correct-Horse-Battery-Staple-E3", home_tenant_id=1)
resp = await client.post(
"/admin/ssh-keys",
json={
"label": "std-key", "private_key_pem": _key_pem(), "public_key": "PUB",
"key_type": "ed25519", "tenant_id": 1,
},
)
assert resp.status_code == 201, resp.text
std_key_id = resp.json()["id"]
client.cookies.clear()
await _login_full(client, "ta_e", "Correct-Horse-Battery-Staple-E2")
resp = await client.get("/admin/users")
assert resp.status_code == 200, resp.text
user_ids = {u["id"] for u in resp.json()}
assert ta_id in user_ids
assert std_user_id not in user_ids
resp = await client.put(f"/admin/users/{std_user_id}", json={"is_active": False})
assert resp.status_code == 404, resp.text
resp = await client.get("/admin/ssh-keys")
key_ids = {k["id"] for k in resp.json()}
assert std_key_id not in key_ids
resp = await client.put(f"/admin/ssh-keys/{std_key_id}", json={"label": "hack"})
assert resp.status_code == 404, resp.text
# Eigenen SSH-Key anlegen -- ohne tenant_id automatisch auf E erzwungen.
resp = await client.post(
"/admin/ssh-keys",
json={"label": "e-key", "private_key_pem": _key_pem(), "public_key": "PUB2", "key_type": "ed25519"},
)
assert resp.status_code == 201, resp.text
e_key_id = resp.json()["id"]
resp = await client.get("/admin/ssh-keys")
assert e_key_id in {k["id"] for k in resp.json()}
@pytest.mark.asyncio
async def test_tenant_admin_isolated_audit_log(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sa6", "Correct-Horse-Battery-Staple-F1", is_admin=True)
await _login_full(client, "sa6", "Correct-Horse-Battery-Staple-F1")
resp = await client.post("/admin/tenants", json={"name": "Kunde F"})
tenant_f = resp.json()["id"]
ta_id = await _create_user(conn, "ta_f", "Correct-Horse-Battery-Staple-F2")
await client.post(f"/admin/tenants/{tenant_f}/admins", json={"user_id": ta_id})
client.cookies.clear()
await _login_full(client, "ta_f", "Correct-Horse-Battery-Staple-F2")
# Eigene Aktion erzeugt einen Audit-Eintrag mit user_id=ta_id.
resp = await client.post("/admin/host-groups", json={"name": "f-team"})
assert resp.status_code == 201, resp.text
resp = await client.get("/admin/audit-log")
assert resp.status_code == 200, resp.text
entries = resp.json()
assert len(entries) >= 1
assert all(e["user_id"] == ta_id for e in entries), "Mandanten-Admin darf nur eigene Aktionen sehen"
# ---------------------------------------------------------------------------
# 3) Mehrfachauswahl bei Rollenvergabe (role_names)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_multi_role_grant_for_individual_user(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "mr_admin", "Correct-Horse-Battery-Staple-G1", is_admin=True)
member_id = await _create_user(conn, "mr_member", "Correct-Horse-Battery-Staple-G2")
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="mr-team", hostname="mr-srv")
await _login_full(client, "mr_admin", "Correct-Horse-Battery-Staple-G1")
resp = await client.post(
"/admin/roles/grant",
json={
"user_id": member_id, "host_group_id": hg_id,
"role_names": ["ssh_connect", "file_transfer"],
},
)
assert resp.status_code == 200, resp.text
assert set(resp.json()["roles_granted"]) == {"ssh_connect", "file_transfer"}
resp = await client.get("/admin/roles")
assert resp.status_code == 200, resp.text
granted_roles = {r["role_name"] for r in resp.json() if r["user_id"] == member_id}
assert {"ssh_connect", "file_transfer"} <= granted_roles
# Einzelne Rolle wieder entziehen -- die andere bleibt bestehen.
resp = await client.post(
"/admin/roles/revoke",
json={"user_id": member_id, "host_group_id": hg_id, "role_name": "file_transfer"},
)
assert resp.status_code == 200, resp.text
resp = await client.get("/admin/roles")
remaining = {r["role_name"] for r in resp.json() if r["user_id"] == member_id}
assert remaining == {"ssh_connect"}
@pytest.mark.asyncio
async def test_multi_role_grant_for_user_group(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "mrg_admin", "Correct-Horse-Battery-Staple-H1", is_admin=True)
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="mrg-team", hostname="mrg-srv")
await _login_full(client, "mrg_admin", "Correct-Horse-Battery-Staple-H1")
resp = await client.post("/admin/user-groups", json={"name": "mrg-group"})
group_id = resp.json()["id"]
resp = await client.post(
"/admin/group-roles/grant",
json={
"user_group_id": group_id, "host_group_id": hg_id,
"role_names": ["ssh_connect", "file_transfer"],
},
)
assert resp.status_code == 200, resp.text
assert set(resp.json()["roles_granted"]) == {"ssh_connect", "file_transfer"}
resp = await client.get("/admin/group-roles")
roles = {r["role_name"] for r in resp.json() if r["user_group_id"] == group_id}
assert {"ssh_connect", "file_transfer"} <= roles
# ---------------------------------------------------------------------------
# 4) CRUD-Vervollstaendigung: Edit/Delete ueberall
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_user_update_deactivate_and_delete_semantics(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "crud_admin", "Correct-Horse-Battery-Staple-I1", is_admin=True)
await _login_full(client, "crud_admin", "Correct-Horse-Battery-Staple-I1")
resp = await client.post(
"/admin/users",
json={"username": "crud_target", "initial_password": "Correct-Horse-Battery-Staple-I2"},
)
assert resp.status_code == 201, resp.text
target_id = resp.json()["id"]
# Update: Passwort setzen -> must_change_password/session_version bumped.
resp = await client.put(f"/admin/users/{target_id}", json={"new_password": "Correct-Horse-Battery-Staple-I3"})
assert resp.status_code == 200, resp.text
assert resp.json()["changed"] is True
# Deactivate.
resp = await client.post(f"/admin/users/{target_id}/deactivate")
assert resp.status_code == 200, resp.text
resp = await client.get("/admin/users")
row = next(u for u in resp.json() if u["id"] == target_id)
assert row["is_active"] is False
# Reaktivieren ueber update.
resp = await client.put(f"/admin/users/{target_id}", json={"is_active": True})
assert resp.status_code == 200, resp.text
# Dieses Konto hat inzwischen Audit-Historie (user_updated/deactivated) ->
# Loeschen anonymisiert statt hart zu entfernen.
resp = await client.delete(f"/admin/users/{target_id}")
assert resp.status_code == 200, resp.text
assert resp.json()["hard_deleted"] is False
resp = await client.get("/admin/users")
assert all(u["id"] != target_id for u in resp.json()), "Anonymisierter User darf nicht mehr gelistet werden"
@pytest.mark.asyncio
async def test_user_without_audit_history_is_hard_deleted(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "crud_admin2", "Correct-Horse-Battery-Staple-J1", is_admin=True)
await _login_full(client, "crud_admin2", "Correct-Horse-Battery-Staple-J1")
resp = await client.post(
"/admin/users",
json={"username": "throwaway", "initial_password": "Correct-Horse-Battery-Staple-J2"},
)
target_id = resp.json()["id"]
# Das einzige Audit-Ereignis, das diesen User referenziert, waere sein
# eigenes Login -- er hat sich nie eingeloggt, also existiert keine
# audit_log-Zeile mit user_id=target_id -> Hard-Delete moeglich.
resp = await client.delete(f"/admin/users/{target_id}")
assert resp.status_code == 200, resp.text
assert resp.json()["hard_deleted"] is True
@pytest.mark.asyncio
async def test_user_cannot_delete_own_account(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "self_admin", "Correct-Horse-Battery-Staple-K1", is_admin=True)
await _login_full(client, "self_admin", "Correct-Horse-Battery-Staple-K1")
resp = await client.get("/auth/me")
my_id = resp.json()["id"]
resp = await client.delete(f"/admin/users/{my_id}")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_host_group_and_host_update_delete(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "hcrud_admin", "Correct-Horse-Battery-Staple-L1", is_admin=True)
await _login_full(client, "hcrud_admin", "Correct-Horse-Battery-Staple-L1")
resp = await client.post("/admin/host-groups", json={"name": "hcrud-team"})
hg_id = resp.json()["id"]
resp = await client.put(f"/admin/host-groups/{hg_id}", json={"description": "Aktualisiert"})
assert resp.status_code == 200, resp.text
resp = await client.post(
"/admin/hosts",
json={
"host_group_id": hg_id, "hostname": "hcrud-srv", "address": "10.2.0.1",
"protocol": "ssh", "port": 22, "os_type": "linux",
},
)
host_id = resp.json()["id"]
resp = await client.put(f"/admin/hosts/{host_id}", json={"hostname": "hcrud-srv-renamed"})
assert resp.status_code == 200, resp.text
resp = await client.get(f"/admin/hosts/{host_id}")
assert resp.json()["hostname"] == "hcrud-srv-renamed"
# Hostgruppe kann nicht geloescht werden, solange sie noch (auch inaktive) Hosts enthaelt.
resp = await client.delete(f"/admin/host-groups/{hg_id}")
assert resp.status_code == 409, resp.text
# Host-Delete ist standardmaessig Soft-Delete.
resp = await client.delete(f"/admin/hosts/{host_id}")
assert resp.status_code == 200, resp.text
assert resp.json()["hard_deleted"] is False
resp = await client.get(f"/admin/hosts/{host_id}")
assert resp.json()["is_active"] is False
# ?hard=true entfernt ihn tatsaechlich (keine Sitzungshistorie vorhanden).
resp = await client.delete(f"/admin/hosts/{host_id}?hard=true")
assert resp.status_code == 200, resp.text
assert resp.json()["hard_deleted"] is True
resp = await client.get(f"/admin/hosts/{host_id}")
assert resp.status_code == 404
resp = await client.delete(f"/admin/host-groups/{hg_id}")
assert resp.status_code == 200, resp.text
@pytest.mark.asyncio
async def test_ssh_key_update_rotate_and_delete(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "kcrud_admin", "Correct-Horse-Battery-Staple-M1", is_admin=True)
await _login_full(client, "kcrud_admin", "Correct-Horse-Battery-Staple-M1")
resp = await client.post(
"/admin/ssh-keys",
json={"label": "orig-key", "private_key_pem": _key_pem(), "public_key": "PUB", "key_type": "ed25519"},
)
key_id = resp.json()["id"]
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "renamed-key"})
assert resp.status_code == 200, resp.text
# Rotation erfordert alle drei Felder gemeinsam (400 noch vor jeder
# Materialpruefung -- der Platzhalter hier ist deshalb Absicht).
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"private_key_pem": "NEWPEM"})
assert resp.status_code == 400, resp.text
resp = await client.put(
f"/admin/ssh-keys/{key_id}",
json={"private_key_pem": _key_pem(), "public_key": "NEWPUB", "key_type": "rsa-4096"},
)
assert resp.status_code == 200, resp.text
resp = await client.delete(f"/admin/ssh-keys/{key_id}")
assert resp.status_code == 200, resp.text
resp = await client.get("/admin/ssh-keys")
assert all(k["id"] != key_id for k in resp.json())
@pytest.mark.asyncio
async def test_user_group_update_and_delete(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "gcrud_admin", "Correct-Horse-Battery-Staple-N1", is_admin=True)
await _login_full(client, "gcrud_admin", "Correct-Horse-Battery-Staple-N1")
resp = await client.post("/admin/user-groups", json={"name": "gcrud-team"})
group_id = resp.json()["id"]
resp = await client.put(f"/admin/user-groups/{group_id}", json={"description": "Aktualisiert"})
assert resp.status_code == 200, resp.text
resp = await client.delete(f"/admin/user-groups/{group_id}")
assert resp.status_code == 200, resp.text
resp = await client.get("/admin/user-groups")
assert all(g["id"] != group_id for g in resp.json())
# ---------------------------------------------------------------------------
# 5) GET /admin/hosts/{id} -- Grundlage fuer den "Details"-Fix
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_host_detail_endpoint_includes_ssh_keys_and_rdp_flag(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "detail_admin", "Correct-Horse-Battery-Staple-O1", is_admin=True)
await _login_full(client, "detail_admin", "Correct-Horse-Battery-Staple-O1")
resp = await client.post("/admin/host-groups", json={"name": "detail-team"})
hg_id = resp.json()["id"]
resp = await client.post(
"/admin/hosts",
json={
"host_group_id": hg_id, "hostname": "win-srv", "address": "10.3.0.1",
"protocol": "rdp", "port": 3389, "os_type": "windows",
},
)
host_id = resp.json()["id"]
resp = await client.post(
"/admin/ssh-keys",
json={"label": "detail-key", "private_key_pem": _key_pem(), "public_key": "PUB", "key_type": "ed25519"},
)
key_id = resp.json()["id"]
resp = await client.post(f"/admin/hosts/{host_id}/ssh-keys/{key_id}")
assert resp.status_code == 200, resp.text
resp = await client.put(
f"/admin/hosts/{host_id}/rdp-credentials",
json={"password": "Correct-Horse-Battery-Staple-O2", "username": "Administrator"},
)
assert resp.status_code == 200, resp.text
resp = await client.get(f"/admin/hosts/{host_id}")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["hostname"] == "win-srv"
assert any(k["id"] == key_id for k in data["ssh_keys"])
assert data["rdp_credentials_set"] is True
resp = await client.get("/admin/rdp-credentials")
assert resp.status_code == 200, resp.text
row = next(r for r in resp.json() if r["host_id"] == host_id)
assert row["credentials_set"] is True
resp = await client.get("/admin/hosts/424242")
assert resp.status_code == 404