mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-07 00:17:20 +08:00
feat: publish refreshed SparkFlow console and send safety
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user