Add debug mode feature flag with environment variable parsing, UI badge indicator, secret redaction utility, and enhanced terminal session management with status checks and session limit error handling
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
2025-12-21 17:22:36 -05:00
parent 421446bd13
commit 70c15c9b6f
16 changed files with 606 additions and 181 deletions
+1
View File
@@ -27,6 +27,7 @@ os.environ["API_KEY"] = "test-api-key-12345"
os.environ["JWT_SECRET_KEY"] = "test-jwt-secret-key-for-testing-only"
os.environ["NTFY_ENABLED"] = "false"
os.environ["ANSIBLE_DIR"] = "."
os.environ["DEBUG_MODE"] = "YES"
from app.models.database import Base
from app.core.dependencies import get_db, verify_api_key, get_current_user
+102
View File
@@ -0,0 +1,102 @@
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, patch
from app.core.config import parse_env_bool, settings
from app.models.terminal_session import TerminalSession, SESSION_STATUS_ACTIVE
class TestParseEnvBool:
def test_parse_env_bool(self):
assert parse_env_bool("YES") is True
assert parse_env_bool("yes") is True
assert parse_env_bool("TRUE") is True
assert parse_env_bool("1") is True
assert parse_env_bool("NO") is False
assert parse_env_bool("no") is False
assert parse_env_bool("FALSE") is False
assert parse_env_bool("0") is False
assert parse_env_bool("invalid") is False
assert parse_env_bool(None) is False
assert parse_env_bool(None, default=True) is True
class TestDebugModeApi:
@pytest.mark.asyncio
async def test_config_endpoint_reflects_debug_mode(self, client, monkeypatch):
monkeypatch.setattr(settings, "debug_mode", False)
resp = await client.get("/api/config")
assert resp.status_code == 200
assert resp.json() == {"debug_mode": False}
@pytest.mark.asyncio
async def test_terminal_status_reports_disabled(self, client, auth_headers, monkeypatch):
monkeypatch.setattr(settings, "debug_mode", False)
with patch("app.services.terminal_service.terminal_service.check_ttyd_available", return_value=True):
resp = await client.get("/api/terminal/status", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["available"] is True
assert data["debug_mode"] is False
@pytest.mark.asyncio
async def test_terminal_connect_allowed_when_debug_disabled(self, client, db_session, monkeypatch):
monkeypatch.setattr(settings, "debug_mode", False)
now = datetime.now(timezone.utc)
sess = TerminalSession(
id="s" * 64,
host_id="host-1",
host_name="test-host",
host_ip="127.0.0.1",
user_id="user-1",
username="testuser",
token_hash="x" * 64,
ttyd_port=7680,
ttyd_pid=123,
mode="embedded",
status=SESSION_STATUS_ACTIVE,
created_at=now,
last_seen_at=now,
expires_at=now + timedelta(minutes=10),
)
db_session.add(sess)
await db_session.commit()
with patch("app.services.terminal_service.terminal_service.verify_token", return_value=True), \
patch("app.services.terminal_service.terminal_service.is_session_process_alive", new=AsyncMock(return_value=True)):
resp = await client.get(f"/api/terminal/connect/{sess.id}?token=dummy")
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_terminal_probe_forbidden_when_debug_disabled(self, client, db_session, monkeypatch):
monkeypatch.setattr(settings, "debug_mode", False)
now = datetime.now(timezone.utc)
sess = TerminalSession(
id="t" * 64,
host_id="host-1",
host_name="test-host",
host_ip="127.0.0.1",
user_id="user-1",
username="testuser",
token_hash="x" * 64,
ttyd_port=7681,
ttyd_pid=123,
mode="embedded",
status=SESSION_STATUS_ACTIVE,
created_at=now,
last_seen_at=now,
expires_at=now + timedelta(minutes=10),
)
db_session.add(sess)
await db_session.commit()
with patch("app.services.terminal_service.terminal_service.verify_token", return_value=True):
resp = await client.get(f"/api/terminal/sessions/{sess.id}/probe?token=dummy")
assert resp.status_code == 403
assert resp.json()["detail"] == "Debug mode disabled"
@@ -35,3 +35,36 @@ class TestTerminalConnectPage:
assert resp.status_code == 200
assert "<html" in resp.text.lower()
assert "terminal" in resp.text.lower()
@pytest.mark.asyncio
async def test_popout_page_allowed_when_debug_disabled(self, client, db_session, monkeypatch):
from app.core.config import settings
monkeypatch.setattr(settings, "debug_mode", False)
now = datetime.now(timezone.utc)
sess = TerminalSession(
id="p" * 64,
host_id="host-1",
host_name="test-host",
host_ip="127.0.0.1",
user_id="user-1",
username="testuser",
token_hash="x" * 64,
ttyd_port=7680,
ttyd_pid=123,
mode="popout",
status=SESSION_STATUS_ACTIVE,
created_at=now,
last_seen_at=now,
expires_at=now + timedelta(minutes=10),
)
db_session.add(sess)
await db_session.commit()
with patch("app.services.terminal_service.terminal_service.verify_token", return_value=True), \
patch("app.services.terminal_service.terminal_service.is_session_process_alive", new=AsyncMock(return_value=True)):
resp = await client.get(f"/api/terminal/popout/{sess.id}?token=dummy")
assert resp.status_code == 200
assert "<html" in resp.text.lower()
+3
View File
@@ -1,6 +1,9 @@
import pytest
from unittest.mock import patch
from app.services.terminal_service import TERMINAL_MAX_SESSIONS_PER_USER
class TestTerminalStatus:
@pytest.mark.asyncio
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
function loadMainJsAsGlobal() {
const mainPath = path.resolve(process.cwd(), 'app', 'main.js');
const source = fs.readFileSync(mainPath, 'utf8');
const wrapped = `${source}\n;globalThis.__DashboardManager = DashboardManager;`;
eval(wrapped);
return globalThis.__DashboardManager;
}
describe('DEBUG_MODE UI gating', () => {
beforeEach(() => {
document.body.innerHTML = `
<div class="desktop-nav-links">
<div class="relative group"></div>
</div>
<div id="hosts-list"></div>
`;
delete globalThis.__DashboardManager;
delete globalThis.DashboardManager;
});
it('shows DEBUG badge only when enabled', () => {
const DashboardManager = loadMainJsAsGlobal();
const dashboard = new DashboardManager();
dashboard.setDebugBadgeVisible(false);
expect(document.getElementById('debug-mode-badge')).toBeNull();
dashboard.setDebugBadgeVisible(true);
expect(document.getElementById('debug-mode-badge')?.textContent).toBe('DEBUG');
dashboard.setDebugBadgeVisible(false);
expect(document.getElementById('debug-mode-badge')).toBeNull();
});
it('mounts terminal buttons when debug is disabled', () => {
const DashboardManager = loadMainJsAsGlobal();
const dashboard = new DashboardManager();
dashboard.debugModeEnabled = false;
dashboard.hosts = [
{
id: 'h1',
name: 'host1',
ip: '127.0.0.1',
os: 'linux',
status: 'online',
bootstrap_ok: true,
groups: ['env_prod', 'role_web'],
last_seen: new Date().toISOString(),
},
];
dashboard.renderHosts();
expect(document.querySelector('[data-action="terminal"]')).not.toBeNull();
expect(document.querySelector('[data-action="terminal-popout"]')).not.toBeNull();
});
it('mounts terminal buttons when debug is enabled', () => {
const DashboardManager = loadMainJsAsGlobal();
const dashboard = new DashboardManager();
dashboard.debugModeEnabled = true;
dashboard.hosts = [
{
id: 'h1',
name: 'host1',
ip: '127.0.0.1',
os: 'linux',
status: 'online',
bootstrap_ok: true,
groups: ['env_prod', 'role_web'],
last_seen: new Date().toISOString(),
},
];
dashboard.renderHosts();
expect(document.querySelector('[data-action="terminal"]')).not.toBeNull();
expect(document.querySelector('[data-action="terminal-popout"]')).not.toBeNull();
});
});
+1
View File
@@ -68,6 +68,7 @@ export function createMockResponse(data, status = 200) {
export function setupFetchMock(responses = {}) {
const defaultResponses = {
'/api/auth/status': { setup_required: false, authenticated: true, user: { username: 'test' } },
'/api/config': { debug_mode: false },
'/api/hosts': [],
'/api/tasks': [],
'/api/schedules': [],