feat: publish refreshed SparkFlow console and send safety

This commit is contained in:
Rixuan Shao
2026-07-11 02:13:14 +08:00
parent 3ae9738c46
commit c496d90039
28 changed files with 4757 additions and 4135 deletions
+1
View File
@@ -0,0 +1 @@
+141
View File
@@ -0,0 +1,141 @@
import os
import unittest
from datetime import datetime, timezone
from unittest.mock import patch
from core import msg_builder, tasks
from core.send_state import history_entry_is_strong_confirmed_today
from webui import ops
class SendStateTests(unittest.TestCase):
def setUp(self):
self.now = datetime(2026, 7, 10, 14, 0, tzinfo=timezone.utc)
self.window = {
"enabled": True,
"startHour": 10,
"endHour": 18,
"scheduleIntervalMinutes": 20,
}
def test_strong_confirmation_is_the_only_sent_state(self):
strong = {
"sentAt": self.now.isoformat(),
"status": "confirmed",
"confirmationLevel": "strong",
"needsVerification": False,
}
weak = {
"sentAt": self.now.isoformat(),
"status": "unconfirmed",
"confirmationLevel": "weak",
"needsVerification": True,
}
legacy = {"sentAt": self.now.isoformat()}
self.assertTrue(history_entry_is_strong_confirmed_today(strong, self.now))
self.assertFalse(history_entry_is_strong_confirmed_today(weak, self.now))
self.assertFalse(history_entry_is_strong_confirmed_today(legacy, self.now))
def test_unconfirmed_target_is_visible_and_retryable(self):
user = {
"username": "demo",
"unique_id": "demo",
"targets": ["target"],
"message_history": {
"target": {
"sentAt": self.now.isoformat(),
"status": "unconfirmed",
"confirmationLevel": "weak",
"needsVerification": True,
}
},
"failure_queue": {
"target": {
"lastAttemptAt": self.now.isoformat(),
"category": "send_unconfirmed",
"attemptCount": 1,
}
},
}
status = ops._build_target_status(user, "target", self.now, self.window)
self.assertEqual("unconfirmed", status["status"])
self.assertFalse(tasks._target_sent_today(user, "target", self.now))
self.assertEqual(["target"], tasks._pending_failed_targets(user, self.now))
self.assertEqual(["target"], tasks._pending_unsent_targets(user, self.now)[0])
def test_legacy_sent_at_only_record_is_retryable(self):
user = {
"targets": ["target"],
"message_history": {"target": {"sentAt": self.now.isoformat()}},
}
status = ops._build_target_status(user, "target", self.now, self.window)
self.assertEqual("unconfirmed", status["status"])
self.assertTrue(status["legacyUnverified"])
self.assertEqual(["target"], tasks._pending_failed_targets(user, self.now))
self.assertEqual(["target"], tasks._pending_unsent_targets(user, self.now)[0])
def test_unsent_retry_respects_non_retryable_and_attempt_limit(self):
user = {
"targets": ["blocked", "exhausted", "retryable"],
"failure_queue": {
"blocked": {
"lastAttemptAt": self.now.isoformat(),
"category": "protocol_user_blocked",
"attemptCount": 1,
},
"exhausted": {
"lastAttemptAt": self.now.isoformat(),
"category": "timeout",
"attemptCount": 3,
},
"retryable": {
"lastAttemptAt": self.now.isoformat(),
"category": "timeout",
"attemptCount": 2,
},
},
}
retryable, skipped = tasks._pending_unsent_targets(user, self.now)
self.assertEqual(["retryable"], retryable)
self.assertEqual(2, len(skipped))
def test_manual_force_all_still_includes_strong_confirmed_targets(self):
user = {
"username": "demo",
"targets": ["confirmed", "pending"],
"message_history": {
"confirmed": {
"sentAt": self.now.isoformat(),
"status": "confirmed",
"confirmationLevel": "strong",
"needsVerification": False,
}
},
}
config = {"dailySendWindow": self.window}
with patch.dict(os.environ, {"SPARKFLOW_MANUAL_RUN": "1"}, clear=False):
prepared = tasks._prepare_active_users_for_run(config, [user])
self.assertEqual(["confirmed", "pending"], prepared[0]["targets"])
def test_message_choice_avoids_previous_and_last_when_possible(self):
with patch.object(
msg_builder,
"build_message_candidates",
return_value=["A", "B", "C"],
):
selected = msg_builder.build_message(previous_message="A", last_message="B")
self.assertEqual("C", selected)
if __name__ == "__main__":
unittest.main()
+161
View File
@@ -0,0 +1,161 @@
import errno
import os
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from core import tasks
from webui import app as app_module
from webui import ops
class WebUiSafetyTests(unittest.TestCase):
def test_windows_invalid_pid_probe_is_treated_as_dead(self):
error = OSError(errno.EINVAL, "invalid pid")
error.winerror = 87
with patch.object(ops.os, "kill", side_effect=error):
self.assertFalse(ops._pid_is_alive(999999))
with patch.object(tasks.os, "kill", side_effect=error):
self.assertFalse(tasks._pid_is_alive(999999))
def test_stale_lock_inspection_does_not_delete_file(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
lock_path = root / "logs" / "task.run.lock"
lock_path.parent.mkdir(parents=True)
lock_path.write_text("99999999\n", encoding="utf-8")
old = time.time() - 10800
os.utime(lock_path, (old, old))
with patch.object(ops, "repo_root", return_value=root):
status = ops.task_run_lock_status()
self.assertTrue(lock_path.exists())
self.assertTrue(status["stale"])
self.assertFalse(status["running"])
self.assertEqual("owner_pid_missing", status["staleReason"])
def test_overview_snapshot_excludes_sensitive_payloads(self):
send_console = {
"now": "2026-07-10T22:00:00+08:00",
"summary": {
"enabled_accounts": 1,
"total_targets": 2,
"today_confirmed_targets": 1,
"today_unconfirmed_targets": 1,
"today_failed_targets": 0,
"today_account_blocked_targets": 0,
"today_attention_targets": 1,
"today_pending_targets": 0,
"today_unprocessed_targets": 0,
"today_remaining_targets": 1,
"today_warning_count": 0,
"last_confirmed_at": "2026-07-10T21:00:00+08:00",
"all_confirmed": False,
},
"accounts": [
{
"unique_id": "account-1",
"username": "Account",
"state": "attention",
"total_targets": 2,
"confirmed_targets": [{"message": "secret message"}],
"attention_count": 1,
"pending_count": 0,
"last_confirmed_at": "2026-07-10T21:00:00+08:00",
}
],
}
with (
patch.object(ops, "get_send_console_snapshot", return_value=send_console),
patch.object(
ops,
"get_schedule_snapshot",
return_value={"label": "10:00-18:00/20m", "nextTriggerAt": ""},
),
patch.object(
ops,
"task_run_lock_status",
return_value={"running": False, "stale": False, "ageSeconds": 0},
),
):
payload = ops.get_overview_snapshot()
serialized = repr(payload)
self.assertNotIn("secret message", serialized)
self.assertNotIn("cookies", serialized)
self.assertNotIn("serverReceipt", serialized)
self.assertNotIn("reason", serialized)
self.assertEqual(1, payload["summary"]["attention"])
def test_primary_pages_and_local_icons_render(self):
client = TestClient(app_module.app, raise_server_exceptions=False)
self.assertEqual(200, client.get("/login").status_code)
self.assertEqual(200, client.get("/static/lucide.min.js").status_code)
with patch.object(app_module, "current_user", return_value="admin"):
for path in ("/", "/ops/send-console", "/ops/logs"):
response = client.get(path)
self.assertEqual(200, response.status_code, path)
self.assertEqual("no-store", response.headers["cache-control"])
def test_overview_api_requires_authentication_and_disables_cache(self):
client = TestClient(app_module.app)
response = client.get("/api/ops/overview")
self.assertEqual(401, response.status_code)
self.assertEqual("no-store", response.headers["cache-control"])
with (
patch.object(app_module, "current_user", return_value="admin"),
patch.object(
app_module,
"get_overview_snapshot",
return_value={
"now": "2026-07-10T22:00:00+08:00",
"schedule": {},
"task": {},
"summary": {},
"accounts": [],
},
),
):
response = client.get("/api/ops/overview")
self.assertEqual(200, response.status_code)
self.assertEqual("no-store", response.headers["cache-control"])
def test_public_settings_and_template_do_not_expose_server_password(self):
with patch.object(
app_module,
"get_app_settings",
return_value={
"server_host": "example",
"server_username": "root",
"server_password": "secret",
"session_secret": "secret",
"admin_password_hash": "hash",
"compose_root": "/opt/app",
"ui_port": 8787,
"login_desktop_api_url": "http://127.0.0.1:18090",
},
):
public = app_module.public_app_settings()
self.assertNotIn("server_password", public)
self.assertNotIn("session_secret", public)
dashboard = (
Path(app_module.TEMPLATES_DIR) / "dashboard.html"
).read_text(encoding="utf-8")
self.assertNotIn("server_password", dashboard)
self.assertNotIn("server_username", dashboard)
self.assertNotIn("server_host", dashboard)
if __name__ == "__main__":
unittest.main()