Compare commits
5 Commits
auto/0129
...
af83e571c6
| Author | SHA1 | Date | |
|---|---|---|---|
| af83e571c6 | |||
| b61626b759 | |||
| 47d80b4479 | |||
| ea3c62d2f2 | |||
| 91c4eed686 |
@@ -77,8 +77,8 @@ struct AgentRow {
|
||||
struct AppState {
|
||||
// Connection
|
||||
char base_url[512] = "https://agents.organic-machine.com";
|
||||
char apikey_buf[256] = "";
|
||||
bool apikey_masked = true;
|
||||
char apikey_buf[256] = ""; // populated from AGENTS_API_KEY env at startup, never via UI
|
||||
bool apikey_from_env = false;
|
||||
bool connected = false;
|
||||
std::string connect_error;
|
||||
long long last_fetch_ms = 0;
|
||||
@@ -160,53 +160,54 @@ static bool db_open(AppState& s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// db_save_connection persists ONLY base_url. apikey lives in env var (sourced
|
||||
// from `pass agentes/api-key`), never on disk.
|
||||
static void db_save_connection(AppState& s) {
|
||||
if (!s.db) return;
|
||||
auto blob = fn_secret::encrypt(s.apikey_buf);
|
||||
if (blob.empty()) {
|
||||
fn_log::log_warn("[db] encrypt failed, not saving apikey");
|
||||
return;
|
||||
}
|
||||
// Upsert connection id=1
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"INSERT INTO connections (id, name, base_url, apikey_encrypted, last_used)"
|
||||
" VALUES (1, 'default', ?, ?, strftime('%s','now'))"
|
||||
" VALUES (1, 'default', ?, x'00', strftime('%s','now'))"
|
||||
" ON CONFLICT(id) DO UPDATE SET"
|
||||
" base_url=excluded.base_url,"
|
||||
" apikey_encrypted=excluded.apikey_encrypted,"
|
||||
" last_used=excluded.last_used;";
|
||||
if (sqlite3_prepare_v2(s.db, sql, -1, &stmt, nullptr) != SQLITE_OK) return;
|
||||
sqlite3_bind_text(stmt, 1, s.base_url, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 2, blob.data(), (int)blob.size(), SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
fn_log::log_info("[db] connection saved");
|
||||
fn_log::log_info("[db] base_url saved");
|
||||
}
|
||||
|
||||
// db_load_connection reads base_url only. apikey is sourced from env var.
|
||||
static void db_load_connection(AppState& s) {
|
||||
if (!s.db) return;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql = "SELECT base_url, apikey_encrypted FROM connections WHERE id=1 LIMIT 1;";
|
||||
const char* sql = "SELECT base_url FROM connections WHERE id=1 LIMIT 1;";
|
||||
if (sqlite3_prepare_v2(s.db, sql, -1, &stmt, nullptr) != SQLITE_OK) return;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char* url = (const char*)sqlite3_column_text(stmt, 0);
|
||||
const void* blob = sqlite3_column_blob(stmt, 1);
|
||||
int blob_sz = sqlite3_column_bytes(stmt, 1);
|
||||
if (url) snprintf(s.base_url, sizeof(s.base_url), "%s", url);
|
||||
if (blob && blob_sz > 0) {
|
||||
std::vector<uint8_t> b((const uint8_t*)blob,
|
||||
(const uint8_t*)blob + blob_sz);
|
||||
std::string key = fn_secret::decrypt(b);
|
||||
if (!key.empty()) {
|
||||
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", key.c_str());
|
||||
fn_log::log_info("[db] credentials loaded");
|
||||
}
|
||||
}
|
||||
if (url && *url) snprintf(s.base_url, sizeof(s.base_url), "%s", url);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
// load_apikey_from_env reads AGENTS_API_KEY into s.apikey_buf. Trims trailing
|
||||
// whitespace (env vars can carry \r on Windows when sourced from .bat).
|
||||
static void load_apikey_from_env(AppState& s) {
|
||||
const char* k = std::getenv("AGENTS_API_KEY");
|
||||
if (!k || !*k) {
|
||||
s.apikey_from_env = false;
|
||||
s.apikey_buf[0] = '\0';
|
||||
return;
|
||||
}
|
||||
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", k);
|
||||
size_t n = strlen(s.apikey_buf);
|
||||
while (n > 0 && (unsigned char)s.apikey_buf[n - 1] <= 0x20) {
|
||||
s.apikey_buf[--n] = '\0';
|
||||
}
|
||||
s.apikey_from_env = (n > 0);
|
||||
}
|
||||
|
||||
static void db_save_state(AppState& s, const char* key, const char* value) {
|
||||
if (!s.db) return;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
@@ -222,13 +223,30 @@ static void db_save_state(AppState& s, const char* key, const char* value) {
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// trim_url removes leading/trailing whitespace and control chars.
|
||||
// Defensive: paste-from-terminal often leaves CR/LF/spaces in the buffer,
|
||||
// which curl reports as "Bad hostname" (exit 3).
|
||||
static std::string trim_url(const std::string& in) {
|
||||
auto is_junk = [](unsigned char c) {
|
||||
return c <= 0x20 || c == 0x7F;
|
||||
};
|
||||
size_t b = 0, e = in.size();
|
||||
while (b < e && is_junk((unsigned char)in[b])) ++b;
|
||||
while (e > b && is_junk((unsigned char)in[e - 1])) --e;
|
||||
return in.substr(b, e - b);
|
||||
}
|
||||
|
||||
static std::string make_url(const AppState& s, const std::string& path) {
|
||||
std::string base = s.base_url;
|
||||
std::string base = trim_url(s.base_url);
|
||||
while (!base.empty() && base.back() == '/') base.pop_back();
|
||||
return base + path;
|
||||
}
|
||||
|
||||
// Parse agents JSON array from /agents endpoint
|
||||
// Parse agents JSON array from /agents endpoint.
|
||||
// Backend shape (agents_and_robots/internal/api/handlers.go):
|
||||
// { id, name, version, desc, enabled: bool, running: bool, pid: int,
|
||||
// instances: int, config_path }
|
||||
// status is derived; uptime/msg_24h not provided yet (v0.2 backend work).
|
||||
static std::vector<AgentRow> parse_agents(const std::string& body) {
|
||||
std::vector<AgentRow> rows;
|
||||
auto j = json::parse(body, nullptr, false);
|
||||
@@ -237,9 +255,13 @@ static std::vector<AgentRow> parse_agents(const std::string& body) {
|
||||
AgentRow r;
|
||||
r.id = a.value("id", "");
|
||||
r.display_name = a.value("name", r.id);
|
||||
r.status = a.value("status", "unknown");
|
||||
r.uptime_s = a.value("uptime_seconds", (long long)0);
|
||||
r.msg_24h = a.value("messages_24h", 0);
|
||||
bool enabled = a.value("enabled", false);
|
||||
bool running = a.value("running", false);
|
||||
if (running) r.status = "running";
|
||||
else if (!enabled) r.status = "disabled";
|
||||
else r.status = "stopped";
|
||||
r.uptime_s = 0;
|
||||
r.msg_24h = a.value("instances", 0); // hack: show instances until backend exposes msg_24h
|
||||
rows.push_back(std::move(r));
|
||||
}
|
||||
return rows;
|
||||
@@ -432,26 +454,20 @@ static void draw_connection_panel(AppState& s) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fn_secret::is_strong()) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.2f, 1.0f),
|
||||
TI_ALERT_TRIANGLE " Linux: apikey uses weak encryption (XOR fallback)");
|
||||
ImGui::Separator();
|
||||
}
|
||||
|
||||
ImGui::Text("Base URL:");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
ImGui::InputText("##base_url", s.base_url, sizeof(s.base_url));
|
||||
|
||||
ImGui::Text("API Key: ");
|
||||
ImGui::Text("API Key:");
|
||||
ImGui::SameLine();
|
||||
ImGuiInputTextFlags key_flags = s.apikey_masked
|
||||
? ImGuiInputTextFlags_Password : ImGuiInputTextFlags_None;
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 80);
|
||||
ImGui::InputText("##apikey", s.apikey_buf, sizeof(s.apikey_buf), key_flags);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(s.apikey_masked ? TI_EYE " Show" : TI_EYE_OFF " Hide")) {
|
||||
s.apikey_masked = !s.apikey_masked;
|
||||
if (s.apikey_from_env) {
|
||||
ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f),
|
||||
TI_CHECK " loaded from AGENTS_API_KEY env (pass agentes/api-key)");
|
||||
} else {
|
||||
ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f),
|
||||
TI_ALERT_TRIANGLE " AGENTS_API_KEY env var missing");
|
||||
ImGui::TextDisabled(" Launch with: AGENTS_API_KEY=$(pass agentes/api-key) <exe>");
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
@@ -777,6 +793,92 @@ static void render() {
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// run_connect_test exercises the same fn_http path the UI uses, against a real
|
||||
// backend. apikey is read from AGENTS_API_KEY env var (never from argv) so it
|
||||
// does not leak via process listings.
|
||||
// stdout: "OK <agents_count>" exit 0
|
||||
// stderr: "FAIL <reason>" exit 1
|
||||
static int run_connect_test(const std::string& base_url) {
|
||||
std::string url = trim_url(base_url);
|
||||
while (!url.empty() && url.back() == '/') url.pop_back();
|
||||
if (url.empty()) {
|
||||
fprintf(stderr, "FAIL empty url after trim\n");
|
||||
return 1;
|
||||
}
|
||||
const char* envk = std::getenv("AGENTS_API_KEY");
|
||||
std::string apikey = envk ? envk : "";
|
||||
{
|
||||
size_t n = apikey.size();
|
||||
while (n > 0 && (unsigned char)apikey[n - 1] <= 0x20) --n;
|
||||
apikey.resize(n);
|
||||
}
|
||||
if (apikey.empty()) {
|
||||
fprintf(stderr, "FAIL AGENTS_API_KEY env var empty/missing\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 1) /health (no auth)
|
||||
{
|
||||
fn_http::Request req;
|
||||
req.method = "GET";
|
||||
req.url = url + "/health";
|
||||
req.timeout_ms = 8000;
|
||||
auto res = fn_http::request(req);
|
||||
if (!res.error.empty()) {
|
||||
fprintf(stderr, "FAIL health transport: %s\n", res.error.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (res.status != 200) {
|
||||
fprintf(stderr, "FAIL health status %d body=%.200s\n",
|
||||
res.status, res.body.c_str());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) /agents sin auth -> 401
|
||||
{
|
||||
fn_http::Request req;
|
||||
req.method = "GET";
|
||||
req.url = url + "/agents";
|
||||
req.timeout_ms = 8000;
|
||||
auto res = fn_http::request(req);
|
||||
if (!res.error.empty()) {
|
||||
fprintf(stderr, "FAIL agents-noauth transport: %s\n", res.error.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (res.status != 401) {
|
||||
fprintf(stderr, "FAIL agents-noauth expected 401 got %d\n", res.status);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) /agents con auth -> 200 con JSON array
|
||||
{
|
||||
fn_http::Request req;
|
||||
req.method = "GET";
|
||||
req.url = url + "/agents";
|
||||
req.bearer_token = apikey;
|
||||
req.timeout_ms = 8000;
|
||||
auto res = fn_http::request(req);
|
||||
if (!res.error.empty()) {
|
||||
fprintf(stderr, "FAIL agents transport: %s\n", res.error.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (res.status != 200) {
|
||||
fprintf(stderr, "FAIL agents status %d body=%.200s\n",
|
||||
res.status, res.body.c_str());
|
||||
return 1;
|
||||
}
|
||||
auto j = json::parse(res.body, nullptr, false);
|
||||
if (j.is_discarded() || !j.is_array()) {
|
||||
fprintf(stderr, "FAIL agents body not JSON array\n");
|
||||
return 1;
|
||||
}
|
||||
fprintf(stdout, "OK %zu\n", j.size());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
// Self-test mode
|
||||
for (int i = 1; i < argc; i++) {
|
||||
@@ -784,6 +886,9 @@ int main(int argc, char** argv) {
|
||||
g_self_test = true;
|
||||
break;
|
||||
}
|
||||
if (strcmp(argv[i], "--connect-test") == 0 && i + 1 < argc) {
|
||||
return run_connect_test(argv[i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (g_self_test) {
|
||||
@@ -809,9 +914,14 @@ int main(int argc, char** argv) {
|
||||
cfg.panels = panels;
|
||||
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
|
||||
|
||||
// Init DB and load saved credentials before rendering
|
||||
// Init DB and load saved base_url + read apikey from env (sourced from `pass agentes/api-key`).
|
||||
db_open(g_state);
|
||||
db_load_connection(g_state);
|
||||
load_apikey_from_env(g_state);
|
||||
if (!g_state.apikey_from_env) {
|
||||
fn_log::log_warn("[startup] AGENTS_API_KEY env var missing — backend calls will fail. "
|
||||
"Launch with: AGENTS_API_KEY=$(pass agentes/api-key) ...");
|
||||
}
|
||||
|
||||
// Cleanup on exit
|
||||
int ret = fn::run_app(cfg, render);
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
E2E connect tests for agents_dashboard against the live backend.
|
||||
|
||||
Runs the deployed .exe with --connect-test, which exercises the SAME
|
||||
fn_http code path the GUI uses (curl spawn via CreateProcessW on Windows).
|
||||
Verifies:
|
||||
|
||||
* /health → 200 sin auth
|
||||
* /agents → 401 sin Bearer
|
||||
* /agents → 200 + JSON array con Bearer
|
||||
|
||||
Skips if:
|
||||
* AGENTS_API_KEY env var missing (run with `pass agentes/api-key`)
|
||||
* Deployed exe not found
|
||||
|
||||
Run from repo root:
|
||||
AGENTS_API_KEY=$(pass agentes/api-key) \\
|
||||
python3 -m pytest -x -q projects/element_agents/apps/agents_dashboard/tests/test_connect_e2e.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
DEFAULT_EXE = Path("/mnt/c/Users/lucas/Desktop/apps/agents_dashboard/agents_dashboard.exe")
|
||||
DEFAULT_URL = "https://agents.organic-machine.com"
|
||||
|
||||
|
||||
def _exe() -> Path:
|
||||
p = Path(os.environ.get("AGENTS_DASHBOARD_EXE", DEFAULT_EXE))
|
||||
if not p.exists():
|
||||
pytest.skip(f"deployed exe not found: {p}")
|
||||
return p
|
||||
|
||||
|
||||
def _url() -> str:
|
||||
return os.environ.get("AGENTS_DASHBOARD_URL", DEFAULT_URL)
|
||||
|
||||
|
||||
def _apikey() -> str:
|
||||
k = os.environ.get("AGENTS_API_KEY", "").strip()
|
||||
if not k:
|
||||
pytest.skip("AGENTS_API_KEY env var missing (try `pass agentes/api-key`)")
|
||||
return k
|
||||
|
||||
|
||||
def _run_connect_test(url: str, env: dict | None = None) -> subprocess.CompletedProcess:
|
||||
"""Invoke .exe --connect-test with WSLENV so env propagates to Windows binary."""
|
||||
full_env = os.environ.copy()
|
||||
if env:
|
||||
full_env.update(env)
|
||||
# WSLENV: comma/colon-separated list of env names to forward to Win32
|
||||
keep = full_env.get("WSLENV", "")
|
||||
add = "AGENTS_API_KEY"
|
||||
full_env["WSLENV"] = f"{keep}:{add}" if keep else add
|
||||
return subprocess.run(
|
||||
[str(_exe()), "--connect-test", url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=full_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_succeeds_with_valid_apikey():
|
||||
"""OK <N> on stdout, exit 0, when env + URL + apikey are valid."""
|
||||
_apikey() # skip if missing
|
||||
r = _run_connect_test(_url())
|
||||
assert r.returncode == 0, f"exit={r.returncode}\nstdout={r.stdout}\nstderr={r.stderr}"
|
||||
assert r.stdout.startswith("OK "), f"stdout=[{r.stdout!r}]"
|
||||
n = int(r.stdout.strip().split()[1])
|
||||
assert n > 0, f"expected at least 1 agent, got {n}"
|
||||
|
||||
|
||||
def test_connect_fails_without_apikey():
|
||||
"""FAIL on stderr, exit 1, when AGENTS_API_KEY is empty."""
|
||||
# Force-empty AGENTS_API_KEY; bypass WSLENV by clearing it too.
|
||||
r = subprocess.run(
|
||||
[str(_exe()), "--connect-test", _url()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": "AGENTS_API_KEY"},
|
||||
)
|
||||
assert r.returncode != 0
|
||||
assert "AGENTS_API_KEY" in r.stderr, f"stderr=[{r.stderr!r}]"
|
||||
|
||||
|
||||
def test_connect_fails_on_bad_host():
|
||||
"""Transport-level failure on unresolvable host."""
|
||||
_apikey()
|
||||
r = _run_connect_test("https://this-host-does-not-exist-xyzzy.invalid")
|
||||
assert r.returncode != 0
|
||||
assert "FAIL" in r.stderr, f"stderr=[{r.stderr!r}]"
|
||||
|
||||
|
||||
def test_count_matches_direct_curl():
|
||||
"""N agents returned by the .exe must equal what /agents returns via curl."""
|
||||
apikey = _apikey()
|
||||
# Direct curl from WSL via Windows curl.exe (matches what fn_http uses).
|
||||
curl_bin = shutil.which("curl.exe") or "/mnt/c/Windows/System32/curl.exe"
|
||||
direct = subprocess.run(
|
||||
[
|
||||
curl_bin, "-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents",
|
||||
],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
assert direct.returncode == 0, f"direct curl failed: {direct.stderr}"
|
||||
expected = len(json.loads(direct.stdout))
|
||||
|
||||
r = _run_connect_test(_url())
|
||||
assert r.returncode == 0
|
||||
got = int(r.stdout.strip().split()[1])
|
||||
assert got == expected, f"exe says {got} agents, curl says {expected}"
|
||||
|
||||
|
||||
def test_url_trim_robust_to_whitespace():
|
||||
"""URL with leading/trailing whitespace + CR/LF must still connect."""
|
||||
_apikey()
|
||||
# Wrap URL with whitespace + CRLF; trim_url() in main.cpp must strip.
|
||||
url = " \r\n " + _url() + " \r\n\t"
|
||||
r = _run_connect_test(url)
|
||||
assert r.returncode == 0, f"trim failed: stderr={r.stderr!r}"
|
||||
assert r.stdout.startswith("OK ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend direct: shape contract + SSE + control endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _curl(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess:
|
||||
curl_bin = shutil.which("curl.exe") or "/mnt/c/Windows/System32/curl.exe"
|
||||
return subprocess.run([curl_bin, *args], capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def test_agents_shape_has_fields_used_by_ui():
|
||||
"""Backend /agents response must carry the fields parse_agents() reads."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
agents = json.loads(r.stdout)
|
||||
assert isinstance(agents, list) and len(agents) > 0
|
||||
required = {"id", "name", "enabled", "running"}
|
||||
for a in agents:
|
||||
missing = required - set(a.keys())
|
||||
assert not missing, f"agent {a.get('id')} missing fields {missing}: {a}"
|
||||
assert isinstance(a["enabled"], bool)
|
||||
assert isinstance(a["running"], bool)
|
||||
|
||||
|
||||
def test_sse_status_streams_not_unsupported():
|
||||
"""/sse/status must return text/event-stream, NOT plain 'streaming unsupported'.
|
||||
|
||||
Regression: statusWriter wrapper used to hide http.Flusher → SSE handlers
|
||||
aborted with 500 'streaming unsupported'. Fixed in agents_and_robots
|
||||
commit 4822208 by adding statusWriter.Flush().
|
||||
"""
|
||||
apikey = _apikey()
|
||||
# -m 3 = max 3s; SSE keeps the connection open, so we capture initial bytes
|
||||
# and stop. -i = include response headers.
|
||||
r = _curl([
|
||||
"-sN", "-i", "-m", "3",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/status",
|
||||
], timeout=8)
|
||||
body = r.stdout
|
||||
assert "streaming unsupported" not in body, f"SSE not flusher-aware: {body[:300]!r}"
|
||||
# Should see SSE content-type header
|
||||
assert "text/event-stream" in body.lower(), f"no SSE content-type: {body[:300]!r}"
|
||||
|
||||
|
||||
def test_sse_logs_streams_not_unsupported():
|
||||
"""/sse/agents/{id}/logs idem."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sN", "-i", "-m", "3",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/agents/assistant-bot/logs",
|
||||
], timeout=8)
|
||||
body = r.stdout
|
||||
assert "streaming unsupported" not in body, f"SSE not flusher-aware: {body[:300]!r}"
|
||||
assert "text/event-stream" in body.lower(), f"no SSE content-type: {body[:300]!r}"
|
||||
|
||||
|
||||
def test_sse_status_emits_initial_ping_within_1s():
|
||||
"""Backend MUST emit ":ping" immediately after headers (regression guard).
|
||||
|
||||
Without this, agents_dashboard sat on "connecting" indefinitely because
|
||||
fgets() blocked waiting for the first body byte.
|
||||
"""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sN", "-m", "2",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/status",
|
||||
], timeout=5)
|
||||
# Body should contain at minimum the ping comment within 2s window
|
||||
assert ": ping" in r.stdout, f"no initial ping in 2s: {r.stdout[:300]!r}"
|
||||
|
||||
|
||||
def test_sse_logs_emits_initial_ping_within_1s():
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sN", "-m", "2",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/agents/assistant-bot/logs",
|
||||
], timeout=5)
|
||||
assert ": ping" in r.stdout, f"no initial ping in 2s: {r.stdout[:300]!r}"
|
||||
|
||||
|
||||
def test_sse_logs_actually_streams_log_lines():
|
||||
"""Beyond the ping, the log SSE must emit `event: log` frames with data.
|
||||
|
||||
assistant-bot is a long-running agent that emits log lines frequently,
|
||||
so we collect a 2s window and expect at least one log event.
|
||||
"""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sN", "-m", "2",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/agents/assistant-bot/logs",
|
||||
], timeout=5)
|
||||
assert "event: log" in r.stdout, f"no log events in 2s window: {r.stdout[:500]!r}"
|
||||
|
||||
|
||||
def test_sse_unauthorized_without_bearer():
|
||||
"""SSE endpoints respect the same auth middleware as REST endpoints."""
|
||||
r = _curl([
|
||||
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"{_url()}/sse/status",
|
||||
])
|
||||
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||
|
||||
|
||||
def test_health_no_auth_required():
|
||||
"""/health must respond 200 without any Authorization header."""
|
||||
r = _curl(["-fsS", f"{_url()}/health"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
body = json.loads(r.stdout)
|
||||
assert body.get("status") == "ok"
|
||||
|
||||
|
||||
def test_agents_unauthorized_without_bearer():
|
||||
"""/agents must return 401 without auth."""
|
||||
r = _curl(["-s", "-o", "/dev/null", "-w", "%{http_code}", f"{_url()}/agents"])
|
||||
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||
|
||||
|
||||
def test_agents_unauthorized_with_bad_bearer():
|
||||
"""/agents must return 401 with wrong apikey."""
|
||||
r = _curl([
|
||||
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-H", "Authorization: Bearer wrong-key-xxxxx",
|
||||
f"{_url()}/agents",
|
||||
])
|
||||
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||
|
||||
|
||||
def test_control_start_endpoint_responds():
|
||||
"""POST /agents/{id}/start returns JSON (idempotent in unified mode).
|
||||
|
||||
Note: in unified mode (current backend), individual start/stop is a partial
|
||||
no-op — Manager.Start returns success since the goroutine is already running.
|
||||
Real per-agent control is a v0.2 backend feature (see issue 0131 if filed).
|
||||
Here we only assert the endpoint is reachable + returns valid JSON.
|
||||
"""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sS", "-X", "POST",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents/test-bot/start",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
body = json.loads(r.stdout)
|
||||
# accept either {status: started, ...} or {error: ...} — only assert it's JSON
|
||||
assert isinstance(body, dict)
|
||||
|
||||
|
||||
def test_get_single_agent_returns_logs():
|
||||
"""GET /agents/{id} must include 'logs' array (might be empty)."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents/assistant-bot",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
body = json.loads(r.stdout)
|
||||
assert body.get("id") == "assistant-bot"
|
||||
assert "logs" in body
|
||||
assert isinstance(body["logs"], list)
|
||||
Reference in New Issue
Block a user