mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-19 23:01:29 +02:00
Settings has six tabs: General, API keys, Proxies, Notifications, AI and Users. Each tab is one card whose controls save on change; list tabs are tables with a row menu and their Add action in the tab row. A non-admin sees General and AI, read-only. Out-of-band testing is the Callback server sheet on the Arsenal Nuclei tab, with a Need a callback count that filters the library to the same rows. Bounty Hub settings are a sheet on the Bounty Hub page. The old settings routes redirect to both. - API keys store the last test result on the row and clear it when the key changes. Providers report whether a test exists. The usage count is gone: only ViewDNS ever incremented it. - Notification channels store the last delivery, written by the notifier on its own connection. A channel offers the seven event types the product emits, program changes on by default, and levels named All events, Warning and critical, Critical only. - A default proxy applies to a scan launched without a context and is preselected for new contexts. A pool hands each scan one endpoint, proxy URLs bracket IPv6 hosts, and deleting a proxy clears it from its contexts. - Users can be added with a role, disabled and promoted. An account cannot change itself, and one that owns records answers 409. - Retention windows are validated and include keep forever. The instance name cannot be blank and names every page title. - The AI list drops the two switches nothing read; each feature states what it sends. The out-of-band Off help says the checks are skipped. Connectors, Bounty Hub and Remote control lose their extra page padding. Migration 5d2e9a8c1b47 adds the test and delivery columns and drops proxies.mode. Claude-Session: https://claude.ai/code/session_01RVAHQxZ7JAkoEiTCDiHYue
303 lines
9.3 KiB
Python
303 lines
9.3 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from fastapi import HTTPException
|
|
|
|
from app.api.v1.api_keys import list_providers
|
|
from app.api.v1.users import create_user, delete_user, update_user
|
|
from app.services.instance_settings import InstanceSettingsService, _check_window
|
|
from app.services.proxy import ProxyService, _summarize
|
|
from shared.definitions.retention import KEEP_FOREVER, SCAN_RETENTION_DAYS
|
|
from shared.enums.api_key import APIProvider
|
|
from shared.models.api_key import APIKey, APIKeyUpdate
|
|
from shared.models.instance_settings import InstanceSettingsUpdate
|
|
from shared.models.notification_channel import NotificationChannel
|
|
from shared.models.project import Project
|
|
from shared.models.proxy import Proxy, ProxyEndpoint, ProxyTestResult
|
|
from shared.models.scan_context import ScanContext
|
|
from shared.models.user import User, UserAdminCreate, UserAdminUpdate
|
|
from shared.services.api_key.async_api_key import APIKeyService
|
|
from shared.services.notifier import _RECORD_DELIVERY, _delivery_rows
|
|
from shared.services.proxy_resolve import resolve_proxy_url
|
|
|
|
pytestmark = pytest.mark.api
|
|
|
|
STRONG = "Vq7#mT9!pLx2@wRz"
|
|
|
|
|
|
@pytest.fixture
|
|
def flush_only(session, monkeypatch):
|
|
monkeypatch.setattr(session, "commit", session.flush)
|
|
return session
|
|
|
|
|
|
async def _admin(estate) -> User:
|
|
return await estate.session.get(User, estate.user_id)
|
|
|
|
|
|
async def _member(session, *, superuser: bool = False) -> User:
|
|
tag = uuid.uuid4().hex[:8]
|
|
user = User(
|
|
username=f"ops{tag}",
|
|
email=f"ops{tag}@test.local",
|
|
hashed_password="x",
|
|
is_superuser=superuser,
|
|
)
|
|
session.add(user)
|
|
await session.flush()
|
|
return user
|
|
|
|
|
|
def _proxy(name: str, endpoints: list[dict], *, default: bool, created_by) -> Proxy:
|
|
return Proxy(
|
|
name=name,
|
|
is_active=True,
|
|
is_default=default,
|
|
endpoints_encrypted=json.dumps(endpoints),
|
|
endpoint_count=len(endpoints),
|
|
created_by=created_by,
|
|
)
|
|
|
|
|
|
# ---------- users ----------
|
|
|
|
|
|
async def test_an_admin_creates_an_account_with_a_role(estate, flush_only):
|
|
tag = uuid.uuid4().hex[:8]
|
|
user = await create_user(
|
|
UserAdminCreate(
|
|
email=f"New{tag}@Test.local",
|
|
username=f"new{tag}",
|
|
password=STRONG,
|
|
is_superuser=True,
|
|
),
|
|
session=flush_only,
|
|
current_user=await _admin(estate),
|
|
)
|
|
assert user.is_superuser
|
|
assert user.email == f"new{tag}@test.local"
|
|
|
|
|
|
async def test_a_taken_username_is_refused(estate, flush_only):
|
|
taken = await _member(flush_only)
|
|
with pytest.raises(HTTPException) as err:
|
|
await create_user(
|
|
UserAdminCreate(
|
|
email="other@test.local", username=taken.username, password=STRONG
|
|
),
|
|
session=flush_only,
|
|
current_user=await _admin(estate),
|
|
)
|
|
assert err.value.status_code == 409
|
|
|
|
|
|
async def test_the_signed_in_account_cannot_disable_itself(estate, flush_only):
|
|
admin = await _admin(estate)
|
|
with pytest.raises(HTTPException) as err:
|
|
await update_user(
|
|
admin.id,
|
|
UserAdminUpdate(is_active=False),
|
|
session=flush_only,
|
|
current_user=admin,
|
|
)
|
|
assert err.value.status_code == 400
|
|
|
|
|
|
async def test_an_admin_disables_and_demotes_another_account(estate, flush_only):
|
|
other = await _member(flush_only, superuser=True)
|
|
updated = await update_user(
|
|
other.id,
|
|
UserAdminUpdate(is_active=False, is_superuser=False),
|
|
session=flush_only,
|
|
current_user=await _admin(estate),
|
|
)
|
|
assert not updated.is_active
|
|
assert not updated.is_superuser
|
|
|
|
|
|
async def test_an_account_that_owns_records_is_not_deleted(estate, flush_only):
|
|
owner = await _member(flush_only)
|
|
flush_only.add(
|
|
Project(
|
|
id=uuid.uuid4(),
|
|
name="Owned",
|
|
slug=f"owned-{owner.id.hex[:8]}",
|
|
created_by=owner.id,
|
|
)
|
|
)
|
|
await flush_only.flush()
|
|
with pytest.raises(HTTPException) as err:
|
|
await delete_user(
|
|
str(owner.id), session=flush_only, current_user=await _admin(estate)
|
|
)
|
|
assert err.value.status_code == 409
|
|
assert "Disable the account" in err.value.detail
|
|
|
|
|
|
# ---------- api keys ----------
|
|
|
|
|
|
async def test_a_key_test_is_stored_and_a_new_key_clears_it(estate, flush_only):
|
|
key = APIKey(provider=APIProvider.VIEWDNS, key_value="first")
|
|
flush_only.add(key)
|
|
await flush_only.flush()
|
|
service = APIKeyService(flush_only)
|
|
|
|
await service.record_test(key, True, "API Key is valid.")
|
|
read = service._to_read(key)
|
|
assert read.last_test_ok is True
|
|
assert read.last_test_message == "API Key is valid."
|
|
|
|
read = await service.update_key(str(key.id), APIKeyUpdate(key_value="second"))
|
|
assert read.last_test_at is None
|
|
assert read.last_test_ok is None
|
|
|
|
|
|
async def test_providers_state_which_keys_can_be_tested(estate, flush_only):
|
|
providers = {
|
|
p.provider: p
|
|
for p in await list_providers(
|
|
_current_user=await _admin(estate), service=APIKeyService(flush_only)
|
|
)
|
|
}
|
|
assert providers[APIProvider.VIEWDNS].testable
|
|
assert providers[APIProvider.TELEGRAM].testable
|
|
assert not providers[APIProvider.CHAOS].testable
|
|
|
|
|
|
# ---------- proxies ----------
|
|
|
|
|
|
async def test_a_scan_without_a_context_uses_the_default_proxy(estate, flush_only):
|
|
fallback = _proxy(
|
|
"fallback",
|
|
[{"scheme": "http", "host": "10.0.0.9", "port": 3128}],
|
|
default=True,
|
|
created_by=estate.user_id,
|
|
)
|
|
chosen = _proxy(
|
|
"chosen",
|
|
[{"scheme": "socks5", "host": "10.0.0.7", "port": 1080}],
|
|
default=False,
|
|
created_by=estate.user_id,
|
|
)
|
|
flush_only.add_all([fallback, chosen])
|
|
await flush_only.flush()
|
|
service = ProxyService(flush_only)
|
|
|
|
assert await service.scan_proxy_url(None) == "http://10.0.0.9:3128"
|
|
assert (
|
|
await service.scan_proxy_url(SimpleNamespace(proxy_id=chosen.id))
|
|
== "socks5://10.0.0.7:1080"
|
|
)
|
|
assert await service.scan_proxy_url(SimpleNamespace(proxy_id=None)) is None
|
|
|
|
|
|
def test_a_pool_hands_each_scan_one_of_its_endpoints():
|
|
pool = SimpleNamespace(
|
|
name="pool",
|
|
is_active=True,
|
|
endpoints_encrypted=json.dumps(
|
|
[
|
|
{"scheme": "http", "host": "10.0.0.1", "port": 8080},
|
|
{"scheme": "http", "host": "10.0.0.2", "port": 8080},
|
|
{"scheme": "http", "host": "2001:db8::5", "port": 8080},
|
|
]
|
|
),
|
|
)
|
|
urls = {resolve_proxy_url(pool) for _ in range(60)}
|
|
assert urls <= {
|
|
"http://10.0.0.1:8080",
|
|
"http://10.0.0.2:8080",
|
|
"http://[2001:db8::5]:8080",
|
|
}
|
|
assert len(urls) > 1
|
|
|
|
|
|
def test_a_pool_test_names_the_endpoint_that_failed():
|
|
endpoints = [
|
|
ProxyEndpoint(scheme="http", host="10.0.0.1", port=8080),
|
|
ProxyEndpoint(scheme="http", host="10.0.0.2", port=8080),
|
|
]
|
|
result = _summarize(
|
|
endpoints,
|
|
[
|
|
ProxyTestResult(success=True, message="Proxy reachable.", latency_ms=90),
|
|
ProxyTestResult(success=False, message="Connection failed: refused"),
|
|
],
|
|
)
|
|
assert not result.success
|
|
assert result.message.startswith("1 of 2 endpoints carried the request.")
|
|
assert "10.0.0.2:8080" in result.message
|
|
|
|
|
|
async def test_a_deleted_proxy_leaves_its_contexts_direct(estate, flush_only):
|
|
doomed = _proxy(
|
|
"doomed",
|
|
[{"scheme": "http", "host": "10.0.0.3", "port": 8080}],
|
|
default=False,
|
|
created_by=estate.user_id,
|
|
)
|
|
flush_only.add(doomed)
|
|
await flush_only.flush()
|
|
context = ScanContext(
|
|
project_id=estate.project_id,
|
|
created_by=estate.user_id,
|
|
name="through doomed",
|
|
proxy_id=doomed.id,
|
|
)
|
|
flush_only.add(context)
|
|
await flush_only.flush()
|
|
|
|
listed = {p.id: p for p in await ProxyService(flush_only).list()}
|
|
assert listed[doomed.id].contexts == 1
|
|
|
|
await ProxyService(flush_only).delete(doomed.id)
|
|
await flush_only.refresh(context)
|
|
assert context.proxy_id is None
|
|
|
|
|
|
# ---------- notifications ----------
|
|
|
|
|
|
async def test_a_delivery_is_recorded_on_the_channel(estate, session):
|
|
channel = NotificationChannel(
|
|
name="soc", provider="slack", config_encrypted="", created_by=estate.user_id
|
|
)
|
|
session.add(channel)
|
|
await session.flush()
|
|
await session.execute(
|
|
_RECORD_DELIVERY, _delivery_rows([(channel.id, False, "HTTP 500.")])
|
|
)
|
|
row = (
|
|
await session.execute(
|
|
sa.select(
|
|
NotificationChannel.last_sent_ok, NotificationChannel.last_sent_message
|
|
).where(NotificationChannel.id == channel.id)
|
|
)
|
|
).one()
|
|
assert row == (False, "HTTP 500.")
|
|
|
|
|
|
# ---------- general ----------
|
|
|
|
|
|
def test_a_retention_window_must_be_an_offered_one():
|
|
_check_window("Scan history", KEEP_FOREVER, SCAN_RETENTION_DAYS)
|
|
with pytest.raises(HTTPException) as err:
|
|
_check_window("Scan history", 45, SCAN_RETENTION_DAYS)
|
|
assert err.value.status_code == 400
|
|
|
|
|
|
async def test_a_blank_instance_name_is_refused(estate, flush_only):
|
|
with pytest.raises(HTTPException) as err:
|
|
await InstanceSettingsService(flush_only).update(
|
|
InstanceSettingsUpdate(instance_name=" ")
|
|
)
|
|
assert err.value.status_code == 400
|