Add API security and async job contracts
This commit is contained in:
@@ -444,7 +444,6 @@ function buildAISetupGuidance() {
|
||||
function getApiBase() {
|
||||
if (window.JYOTISH_API_BASE) return window.JYOTISH_API_BASE;
|
||||
if (import.meta.env?.VITE_JYOTISH_API_BASE) return import.meta.env.VITE_JYOTISH_API_BASE;
|
||||
if (window.Capacitor?.isNativePlatform?.()) return localStorage.getItem('jyotish_api_base') || '';
|
||||
return ''; // 同域部署
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ async function postJson(path, payload, { requireModernChart = false } = {}) {
|
||||
continue;
|
||||
}
|
||||
activeApiBase = base;
|
||||
if (data?.mode === 'async_submitted') return pollAsyncJob(data, { base });
|
||||
return data;
|
||||
} catch (error) {
|
||||
lastAttempt = `${base}${path}`;
|
||||
@@ -60,6 +61,22 @@ async function postJson(path, payload, { requireModernChart = false } = {}) {
|
||||
throw lastError || new Error(buildAPIRecoveryMessage(path, '本地 API 未连接', lastAttempt));
|
||||
}
|
||||
|
||||
async function pollAsyncJob(job, { base = activeApiBase, timeoutMs = 120000, intervalMs = 500 } = {}) {
|
||||
if (!job?.poll_path || !job?.access_token) throw new Error('Async job response missing poll capability');
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const resp = await fetch(`${base}${job.poll_path}`, {
|
||||
headers: { Authorization: `Bearer ${job.access_token}` },
|
||||
});
|
||||
const data = await parseApiResponse(resp);
|
||||
if (!resp.ok) throw new Error(buildAPIRecoveryMessage(job.poll_path, data?.error || `Job poll failed (${resp.status})`));
|
||||
if (data.status === 'completed') return data.result || data;
|
||||
if (data.status === 'failed') throw new Error(data.error || 'Async job failed');
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
throw new Error('Async job timed out');
|
||||
}
|
||||
|
||||
async function fetchJson(path) {
|
||||
let lastError = null;
|
||||
let lastAttempt = null;
|
||||
@@ -511,6 +528,7 @@ window.JyotishAPI = {
|
||||
computeKakshya,
|
||||
computeBhavaBala,
|
||||
computeTransitTriggers,
|
||||
pollAsyncJob,
|
||||
// AI 解读
|
||||
aiReading,
|
||||
aiFullReading,
|
||||
|
||||
+1
-2
@@ -12,7 +12,6 @@ import { escapeAttr, escapeHtml } from './security.js';
|
||||
const API_BASE = ''; // 同域部署,留空;Capacitor 打包时改为服务器地址
|
||||
const TOKEN_KEY = 'jyotish_auth_token';
|
||||
const USER_KEY = 'jyotish_auth_user';
|
||||
const API_BASE_KEY = 'jyotish_api_base';
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
@@ -61,7 +60,7 @@ export function getUser() { return _user; }
|
||||
export function isLoggedIn() { return !!_token && !!_user; }
|
||||
|
||||
export function getApiBase() {
|
||||
return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || localStorage.getItem(API_BASE_KEY) || API_BASE;
|
||||
return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || API_BASE;
|
||||
}
|
||||
|
||||
export function onAuthChange(cb) { _onAuthChange = cb; }
|
||||
|
||||
+235
-14
@@ -15,10 +15,13 @@ import json, sys, os, math
|
||||
import importlib.util
|
||||
import hashlib
|
||||
import re
|
||||
import sqlite3
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -57,6 +60,75 @@ _LOCAL_MODULE_CACHE = {}
|
||||
_API_CHART_CACHE_SCOPE = 'api_chart_response'
|
||||
_HIGH_RIGOR_JOB_SCOPE = 'high_rigor_workflow'
|
||||
_UNIFIED_CONSULTATION_ORCHESTRATOR = UnifiedConsultationOrchestrator()
|
||||
_ASYNC_JOB_WORKERS = max(int(os.environ.get('JYOTISH_ASYNC_JOB_WORKERS', '2')), 1)
|
||||
_ASYNC_JOB_QUEUE_SIZE = max(int(os.environ.get('JYOTISH_ASYNC_JOB_QUEUE_SIZE', '8')), 0)
|
||||
_ASYNC_JOB_EXECUTOR = ThreadPoolExecutor(
|
||||
max_workers=_ASYNC_JOB_WORKERS,
|
||||
thread_name_prefix='jyotish-job',
|
||||
)
|
||||
_ASYNC_JOB_CAPACITY = threading.BoundedSemaphore(_ASYNC_JOB_WORKERS + _ASYNC_JOB_QUEUE_SIZE)
|
||||
_RATE_LIMIT_LOCK = threading.Lock()
|
||||
_RATE_LIMIT_BUCKETS: dict[str, tuple[float, int]] = {}
|
||||
|
||||
|
||||
def summarize_execution_status(result: dict | None) -> dict:
|
||||
result = result if isinstance(result, dict) else {}
|
||||
fallback = str(result.get('fallback_reason') or '')
|
||||
official = 'official_blocked' if 'VedAstro official snapshot blocked' in fallback else result.get('official_evidence_status', 'unknown')
|
||||
return {
|
||||
'official_evidence_status': official,
|
||||
'fallback_reason': result.get('fallback_reason'),
|
||||
}
|
||||
|
||||
|
||||
def build_evidence_packet_view(job_record: dict | None) -> dict:
|
||||
"""Public, token-protected job view. Excludes prompt internals and raw input."""
|
||||
job_record = job_record or {}
|
||||
result = job_record.get('result')
|
||||
result = result if isinstance(result, dict) else {}
|
||||
return {
|
||||
'scope': 'evidence_packet_view',
|
||||
'job_id': job_record.get('job_id'),
|
||||
'status': job_record.get('status', 'unknown'),
|
||||
'execution_status': summarize_execution_status(result),
|
||||
'machine_evidence_packet': result.get('machine_evidence_packet') or {},
|
||||
'technique_audit': result.get('technique_audit') or result.get('technique_audit_table') or [],
|
||||
'warnings': result.get('warnings') or [],
|
||||
}
|
||||
|
||||
|
||||
def _submit_background_job(callback):
|
||||
if not _ASYNC_JOB_CAPACITY.acquire(blocking=False):
|
||||
raise JobQueueFull('Async job queue is full')
|
||||
try:
|
||||
future = _ASYNC_JOB_EXECUTOR.submit(callback)
|
||||
except Exception:
|
||||
_ASYNC_JOB_CAPACITY.release()
|
||||
raise
|
||||
future.add_done_callback(lambda _future: _ASYNC_JOB_CAPACITY.release())
|
||||
return future
|
||||
|
||||
|
||||
def _rate_limit_per_minute() -> int:
|
||||
raw = str(os.environ.get('JYOTISH_API_RATE_LIMIT_PER_MINUTE', '120')).strip()
|
||||
try:
|
||||
return max(int(raw), 0)
|
||||
except ValueError:
|
||||
return 120
|
||||
|
||||
|
||||
def enforce_rate_limit(client_id: str, *, now: float | None = None) -> None:
|
||||
limit = _rate_limit_per_minute()
|
||||
if limit == 0:
|
||||
return
|
||||
now = time.time() if now is None else now
|
||||
with _RATE_LIMIT_LOCK:
|
||||
window, count = _RATE_LIMIT_BUCKETS.get(client_id, (now, 0))
|
||||
if now - window >= 60:
|
||||
window, count = now, 0
|
||||
if count >= limit:
|
||||
raise RateLimited('Rate limit exceeded')
|
||||
_RATE_LIMIT_BUCKETS[client_id] = (window, count + 1)
|
||||
|
||||
|
||||
def _western_evidence_packet_from_body(
|
||||
@@ -752,29 +824,140 @@ def _async_job_path(scope: str, job_id: str) -> Path:
|
||||
return _async_job_dir(scope) / f'{job_id}.json'
|
||||
|
||||
|
||||
def _load_high_rigor_job_record(job_id: str) -> dict | None:
|
||||
return _load_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id)
|
||||
def _async_job_ttl_seconds() -> float:
|
||||
raw = str(os.environ.get('JYOTISH_ASYNC_JOB_TTL_SECONDS', '3600')).strip()
|
||||
try:
|
||||
return max(float(raw), 1.0)
|
||||
except ValueError:
|
||||
return 3600.0
|
||||
|
||||
|
||||
def _async_job_backend() -> str:
|
||||
return "sqlite" if os.environ.get("JYOTISH_ASYNC_JOB_BACKEND", "file").strip().lower() == "sqlite" else "file"
|
||||
|
||||
|
||||
def _sqlite_job_db_path() -> Path:
|
||||
return Path(REPO_ROOT) / "scratch" / "local" / "async_jobs.sqlite3"
|
||||
|
||||
|
||||
def _sqlite_job_connection() -> sqlite3.Connection:
|
||||
path = _sqlite_job_db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(path, timeout=10)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS async_jobs (scope TEXT NOT NULL, job_id TEXT NOT NULL, expires_at REAL, payload TEXT NOT NULL, PRIMARY KEY (scope, job_id))"
|
||||
)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return connection
|
||||
|
||||
|
||||
def prune_expired_async_jobs() -> dict:
|
||||
"""Best-effort startup cleanup for local job records; never reads payloads."""
|
||||
removed = 0
|
||||
scanned = 0
|
||||
if _async_job_backend() == "sqlite":
|
||||
with _sqlite_job_connection() as connection:
|
||||
scanned = connection.execute("SELECT COUNT(*) FROM async_jobs").fetchone()[0]
|
||||
removed = connection.execute(
|
||||
"DELETE FROM async_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?", (time.time(),)
|
||||
).rowcount
|
||||
return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed}
|
||||
for scope in (_HIGH_RIGOR_JOB_SCOPE, _API_CHART_CACHE_SCOPE):
|
||||
directory = _async_job_dir(scope)
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
for path in directory.glob('*.json'):
|
||||
scanned += 1
|
||||
try:
|
||||
record = json.loads(path.read_text(encoding='utf-8'))
|
||||
expires_at = record.get('expires_at_unix') if isinstance(record, dict) else None
|
||||
if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at):
|
||||
path.unlink()
|
||||
removed += 1
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed}
|
||||
|
||||
|
||||
def _new_async_job_identity(prefix: str) -> dict:
|
||||
return {
|
||||
'job_id': f'{prefix}_{secrets.token_hex(16)}',
|
||||
'access_token': secrets.token_urlsafe(32),
|
||||
}
|
||||
|
||||
|
||||
def _access_token_hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _load_high_rigor_job_record(job_id: str, *, access_token: str = '') -> dict | None:
|
||||
return _load_async_job_record(
|
||||
_HIGH_RIGOR_JOB_SCOPE,
|
||||
job_id,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
|
||||
def _write_high_rigor_job_record(job_id: str, payload: dict) -> dict:
|
||||
return _write_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id, payload)
|
||||
|
||||
|
||||
def _load_async_job_record(scope: str, job_id: str) -> dict | None:
|
||||
path = _async_job_path(scope, job_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
def _load_async_job_record(scope: str, job_id: str, *, access_token: str = '') -> dict | None:
|
||||
path = None
|
||||
if _async_job_backend() == "sqlite":
|
||||
with _sqlite_job_connection() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload FROM async_jobs WHERE scope = ? AND job_id = ?", (scope, job_id)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
record = json.loads(row[0])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
else:
|
||||
path = _async_job_path(scope, job_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
record = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
expires_at = record.get('expires_at_unix')
|
||||
if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at):
|
||||
if _async_job_backend() == "sqlite":
|
||||
with _sqlite_job_connection() as connection:
|
||||
connection.execute("DELETE FROM async_jobs WHERE scope = ? AND job_id = ?", (scope, job_id))
|
||||
elif path is not None:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
expected = record.get('access_token_hash')
|
||||
if not isinstance(expected, str) or not access_token:
|
||||
raise JobAccessDenied('Async job access token required')
|
||||
if not secrets.compare_digest(expected, _access_token_hash(access_token)):
|
||||
raise JobAccessDenied('Async job access token invalid')
|
||||
return record
|
||||
|
||||
|
||||
def _write_async_job_record(scope: str, job_id: str, payload: dict) -> dict:
|
||||
_async_job_path(scope, job_id).write_text(
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True),
|
||||
encoding='utf-8',
|
||||
)
|
||||
if _async_job_backend() == "sqlite":
|
||||
with _sqlite_job_connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT OR REPLACE INTO async_jobs (scope, job_id, expires_at, payload) VALUES (?, ?, ?, ?)",
|
||||
(scope, job_id, payload.get("expires_at_unix"), json.dumps(payload, ensure_ascii=False, sort_keys=True)),
|
||||
)
|
||||
return payload
|
||||
path = _async_job_path(scope, job_id)
|
||||
temp_path = path.with_suffix(f'.{secrets.token_hex(8)}.tmp')
|
||||
temp_path.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True), encoding='utf-8')
|
||||
os.chmod(temp_path, 0o600)
|
||||
os.replace(temp_path, path)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1098,6 +1281,26 @@ class BadRequest(ValueError):
|
||||
"""Client-side request validation failed."""
|
||||
|
||||
|
||||
class Forbidden(PermissionError):
|
||||
"""Request failed the local API trust boundary."""
|
||||
|
||||
|
||||
class UnsupportedMediaType(ValueError):
|
||||
"""Request body media type is not supported."""
|
||||
|
||||
|
||||
class JobAccessDenied(PermissionError):
|
||||
"""Async job capability token is missing or invalid."""
|
||||
|
||||
|
||||
class JobQueueFull(RuntimeError):
|
||||
"""Bounded async worker queue has no remaining capacity."""
|
||||
|
||||
|
||||
class RateLimited(RuntimeError):
|
||||
"""Client exceeded the local fixed-window request budget."""
|
||||
|
||||
|
||||
class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
server_version = 'JyotishAPI/6.9.14'
|
||||
|
||||
@@ -1121,6 +1324,19 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
if origin in allowed:
|
||||
self.send_header('Access-Control-Allow-Origin', origin)
|
||||
|
||||
def _enforce_request_security(self, *, require_json=False):
|
||||
origin = self.headers.get('Origin')
|
||||
allowed = getattr(self.server, 'allowed_origins', DEFAULT_ALLOWED_ORIGINS)
|
||||
if origin and origin not in allowed:
|
||||
raise Forbidden('Origin is not allowed')
|
||||
host = (self.headers.get('Host') or '').split(':', 1)[0].strip('[]').lower()
|
||||
if host and host not in {'localhost', '127.0.0.1', '::1'}:
|
||||
raise Forbidden('Host is not allowed')
|
||||
if require_json:
|
||||
content_type = (self.headers.get('Content-Type') or '').split(';', 1)[0].strip().lower()
|
||||
if content_type != 'application/json':
|
||||
raise UnsupportedMediaType('Content-Type must be application/json')
|
||||
|
||||
def _vedastro_status(self):
|
||||
adapter = _load_local_module('vedastro_service_adapter')
|
||||
endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip()
|
||||
@@ -1234,6 +1450,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
self._enforce_request_security(require_json=True)
|
||||
body = self._read_json_body()
|
||||
if path == '/api/chart':
|
||||
result = self._compute_chart(body)
|
||||
@@ -1378,6 +1595,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
self._error_json(f'Unknown endpoint: {path}', 404, 'ERR_NOT_FOUND')
|
||||
except BadRequest as e:
|
||||
self._error_json(str(e), 400, 'ERR_BAD_REQUEST')
|
||||
except Forbidden as e:
|
||||
self._error_json(str(e), 403, 'ERR_FORBIDDEN')
|
||||
except UnsupportedMediaType as e:
|
||||
self._error_json(str(e), 415, 'ERR_UNSUPPORTED_MEDIA_TYPE')
|
||||
except Exception:
|
||||
import logging
|
||||
logging.exception("[api_server] request failed for %s", path)
|
||||
|
||||
@@ -29,6 +29,7 @@ import sys
|
||||
import re
|
||||
import glob
|
||||
import argparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import markdown
|
||||
@@ -323,6 +324,13 @@ def build_cover(name, lagna, gender, status, pkg, desc, lang="cn"):
|
||||
</div>"""
|
||||
|
||||
|
||||
def is_allowed_report_resource_url(url, *, report_url):
|
||||
if url == report_url:
|
||||
return True
|
||||
parsed = urlparse(url)
|
||||
return parsed.scheme in {'data', 'about', 'blob'}
|
||||
|
||||
|
||||
def build_toc(sections, lang="cn"):
|
||||
"""Generate table of contents HTML."""
|
||||
toc_title = "目录" if lang == "cn" else "Table of Contents"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import jyotish_api_server as api
|
||||
|
||||
|
||||
def test_evidence_packet_view_exposes_only_auditable_result_sections():
|
||||
packet = api.build_evidence_packet_view({
|
||||
"job_id": "job_1",
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"fallback_reason": "VedAstro official snapshot blocked: timeout",
|
||||
"machine_evidence_packet": {"status": "draft", "metadata": {"capture_id": "x"}},
|
||||
"technique_audit": [{"technique": "D9", "status": "used"}],
|
||||
"ai_prompt_pack": {"prompt_zh": "internal prompt"},
|
||||
},
|
||||
})
|
||||
|
||||
assert packet["job_id"] == "job_1"
|
||||
assert packet["execution_status"]["official_evidence_status"] == "official_blocked"
|
||||
assert packet["machine_evidence_packet"]["metadata"]["capture_id"] == "x"
|
||||
assert "ai_prompt_pack" not in packet
|
||||
|
||||
|
||||
def test_async_job_route_extracts_id_before_loading(monkeypatch, tmp_path):
|
||||
record = {"job_id": "chart_abc", "status": "completed", "result": {}}
|
||||
monkeypatch.setattr(api, "_load_async_job_record", lambda *args, **kwargs: record)
|
||||
|
||||
handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler)
|
||||
handler.path = "/api/chart/jobs/chart_abc"
|
||||
handler.headers = {"Origin": ""}
|
||||
handler._enforce_request_security = lambda: None
|
||||
captured = {}
|
||||
handler._json = lambda data, status=200: captured.update(data=data, status=status)
|
||||
handler._error_json = lambda message, status=500, error_code="ERR_INTERNAL": captured.update(error=error_code, status=status)
|
||||
handler._job_access_token = lambda: "token"
|
||||
|
||||
handler.do_GET()
|
||||
|
||||
assert captured["status"] == 200
|
||||
assert captured["data"]["job_id"] == "chart_abc"
|
||||
|
||||
|
||||
def test_evidence_packet_page_is_present_and_does_not_embed_birth_data():
|
||||
page = Path(api.REPO_ROOT) / "web" / "evidence_packet.html"
|
||||
source = page.read_text(encoding="utf-8")
|
||||
|
||||
assert "Evidence Packet" in source
|
||||
assert "access token" in source
|
||||
assert "birth" not in source.lower()
|
||||
|
||||
|
||||
def test_rectification_page_uses_choice_questionnaire_contract():
|
||||
page = Path(api.REPO_ROOT) / "web" / "rectification.html"
|
||||
source = page.read_text(encoding="utf-8")
|
||||
|
||||
assert "/api/rectification/questionnaire" in source
|
||||
assert "/api/rectification/answers" in source
|
||||
assert "候选簇排序" in source
|
||||
|
||||
|
||||
def test_home_page_keeps_location_confirmation_local():
|
||||
page = Path(api.REPO_ROOT) / "web" / "index.html"
|
||||
source = page.read_text(encoding="utf-8")
|
||||
|
||||
assert "/api/location/resolve" in source
|
||||
assert "第三方地理服务" in source
|
||||
|
||||
|
||||
def test_startup_cleanup_removes_only_expired_job_records(monkeypatch, tmp_path):
|
||||
expired = tmp_path / "expired.json"
|
||||
active = tmp_path / "active.json"
|
||||
expired.write_text(json.dumps({"expires_at_unix": time.time() - 1}), encoding="utf-8")
|
||||
active.write_text(json.dumps({"expires_at_unix": time.time() + 60}), encoding="utf-8")
|
||||
monkeypatch.setattr(api, "_async_job_dir", lambda scope: tmp_path)
|
||||
|
||||
result = api.prune_expired_async_jobs()
|
||||
|
||||
assert result["removed"] == 1
|
||||
assert not expired.exists()
|
||||
assert active.exists()
|
||||
|
||||
|
||||
def test_rate_limit_is_configurable_and_rejects_over_budget(monkeypatch):
|
||||
api._RATE_LIMIT_BUCKETS.clear()
|
||||
monkeypatch.setenv("JYOTISH_API_RATE_LIMIT_PER_MINUTE", "1")
|
||||
|
||||
api.enforce_rate_limit("test-client", now=0)
|
||||
with pytest.raises(api.RateLimited):
|
||||
api.enforce_rate_limit("test-client", now=1)
|
||||
|
||||
|
||||
def test_sqlite_async_job_backend_preserves_token_and_ttl(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("JYOTISH_ASYNC_JOB_BACKEND", "sqlite")
|
||||
monkeypatch.setattr(api, "_sqlite_job_db_path", lambda: tmp_path / "jobs.sqlite3")
|
||||
record = {"job_id": "job_sqlite", "access_token_hash": api._access_token_hash("secret"), "expires_at_unix": time.time() + 60}
|
||||
|
||||
api._write_async_job_record("test_scope", "job_sqlite", record)
|
||||
|
||||
assert api._load_async_job_record("test_scope", "job_sqlite", access_token="secret")["job_id"] == "job_sqlite"
|
||||
with pytest.raises(api.JobAccessDenied):
|
||||
api._load_async_job_record("test_scope", "job_sqlite", access_token="wrong")
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import jyotish_api_server as api # noqa: E402
|
||||
import report_builder # noqa: E402
|
||||
|
||||
|
||||
class _Headers(dict):
|
||||
def get(self, key, default=None):
|
||||
return super().get(key, default)
|
||||
|
||||
|
||||
class _Server:
|
||||
allowed_origins = {"http://localhost:3456"}
|
||||
server_address = ("127.0.0.1", 5200)
|
||||
|
||||
|
||||
def _handler(headers: dict[str, str]):
|
||||
handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler)
|
||||
handler.headers = _Headers(headers)
|
||||
handler.server = _Server()
|
||||
return handler
|
||||
|
||||
|
||||
def test_untrusted_origin_is_rejected_before_post_side_effects() -> None:
|
||||
handler = _handler(
|
||||
{
|
||||
"Origin": "https://evil.example",
|
||||
"Host": "127.0.0.1:5200",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
with pytest.raises(api.Forbidden, match="Origin"):
|
||||
handler._enforce_request_security(require_json=True)
|
||||
|
||||
|
||||
def test_post_requires_json_content_type() -> None:
|
||||
handler = _handler(
|
||||
{
|
||||
"Origin": "http://localhost:3456",
|
||||
"Host": "127.0.0.1:5200",
|
||||
"Content-Type": "text/plain",
|
||||
}
|
||||
)
|
||||
with pytest.raises(api.UnsupportedMediaType):
|
||||
handler._enforce_request_security(require_json=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://example.com/image.png",
|
||||
"http://127.0.0.1:8080/private",
|
||||
"file:///etc/passwd",
|
||||
"ftp://example.com/file",
|
||||
],
|
||||
)
|
||||
def test_report_renderer_blocks_external_and_local_resources(url: str) -> None:
|
||||
assert report_builder.is_allowed_report_resource_url(
|
||||
url,
|
||||
report_url="file:///tmp/report.html",
|
||||
) is False
|
||||
|
||||
|
||||
def test_report_renderer_allows_only_document_and_embedded_resources() -> None:
|
||||
assert report_builder.is_allowed_report_resource_url(
|
||||
"file:///tmp/report.html",
|
||||
report_url="file:///tmp/report.html",
|
||||
) is True
|
||||
assert report_builder.is_allowed_report_resource_url(
|
||||
"data:image/png;base64,AA==",
|
||||
report_url="file:///tmp/report.html",
|
||||
) is True
|
||||
|
||||
|
||||
def test_async_job_identity_is_random_and_capability_protected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(api, "_async_job_dir", lambda _scope: tmp_path)
|
||||
first = api._new_async_job_identity("chart")
|
||||
second = api._new_async_job_identity("chart")
|
||||
assert first["job_id"] != second["job_id"]
|
||||
assert len(first["job_id"].split("_", 1)[1]) >= 32
|
||||
assert first["access_token"] != second["access_token"]
|
||||
|
||||
record = {
|
||||
"job_id": first["job_id"],
|
||||
"status": "queued",
|
||||
"access_token_hash": hashlib.sha256(first["access_token"].encode()).hexdigest(),
|
||||
"expires_at_unix": time.time() + 60,
|
||||
}
|
||||
api._write_async_job_record("chart", first["job_id"], record)
|
||||
assert api._load_async_job_record(
|
||||
"chart", first["job_id"], access_token=first["access_token"]
|
||||
)["status"] == "queued"
|
||||
with pytest.raises(api.JobAccessDenied):
|
||||
api._load_async_job_record("chart", first["job_id"], access_token="wrong")
|
||||
|
||||
|
||||
def test_expired_async_job_is_deleted(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(api, "_async_job_dir", lambda _scope: tmp_path)
|
||||
identity = api._new_async_job_identity("chart")
|
||||
api._write_async_job_record(
|
||||
"chart",
|
||||
identity["job_id"],
|
||||
{
|
||||
"job_id": identity["job_id"],
|
||||
"access_token_hash": hashlib.sha256(identity["access_token"].encode()).hexdigest(),
|
||||
"expires_at_unix": time.time() - 1,
|
||||
},
|
||||
)
|
||||
assert api._load_async_job_record(
|
||||
"chart", identity["job_id"], access_token=identity["access_token"]
|
||||
) is None
|
||||
assert not (tmp_path / f"{identity['job_id']}.json").exists()
|
||||
|
||||
|
||||
def test_authenticated_frontend_does_not_use_local_storage_api_base() -> None:
|
||||
auth_source = (ROOT / "jyotish-app" / "auth.js").read_text(encoding="utf-8")
|
||||
chat_source = (ROOT / "jyotish-app" / "ai-chat.js").read_text(encoding="utf-8")
|
||||
assert "localStorage.getItem(API_BASE_KEY)" not in auth_source
|
||||
assert "localStorage.getItem('jyotish_api_base')" not in chat_source
|
||||
|
||||
|
||||
def test_frontend_async_poll_uses_ephemeral_job_capability() -> None:
|
||||
bridge_source = (ROOT / "jyotish-app" / "api-bridge.js").read_text(encoding="utf-8")
|
||||
assert "pollAsyncJob(data, { base })" in bridge_source
|
||||
assert "Authorization: `Bearer ${job.access_token}`" in bridge_source
|
||||
assert "sessionStorage.setItem('jyotish_job" not in bridge_source
|
||||
|
||||
|
||||
def test_background_job_queue_rejects_when_capacity_is_full(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _FullCapacity:
|
||||
def acquire(self, blocking=False):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(api, "_ASYNC_JOB_CAPACITY", _FullCapacity())
|
||||
with pytest.raises(api.JobQueueFull):
|
||||
api._submit_background_job(lambda: None)
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Jyotish Evidence Packet</title>
|
||||
<style>
|
||||
body{margin:0;background:#f5f7f8;color:#17212b;font:15px system-ui,-apple-system,"PingFang SC",sans-serif}
|
||||
main{max-width:960px;margin:0 auto;padding:28px 18px 56px}.bar{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin:18px 0}
|
||||
input,button{font:inherit;padding:10px;border:1px solid #b6c1c6;border-radius:4px}button{background:#006b6b;color:white;border-color:#006b6b;cursor:pointer}
|
||||
section{background:white;border:1px solid #dce3e5;border-radius:6px;padding:18px;margin:14px 0}h1,h2{margin:0 0 10px}h1{font-size:24px}h2{font-size:17px}pre{white-space:pre-wrap;word-break:break-word;margin:0}.badge{display:inline-block;padding:3px 8px;border-radius:99px;background:#e1f2f0;color:#075d5a}
|
||||
@media(max-width:640px){.bar{grid-template-columns:1fr}main{padding:20px 12px}}
|
||||
</style>
|
||||
<main>
|
||||
<h1>Evidence Packet</h1>
|
||||
<p>仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。</p>
|
||||
<div class="bar"><input id="url" placeholder="/api/evidence_packet/high_rigor_workflow/{job_id}"><input id="token" placeholder="access token"><button id="load">加载</button></div>
|
||||
<section><h2>运行状态</h2><div id="status">等待加载</div></section>
|
||||
<section><h2>Technique Audit</h2><pre id="audit">-</pre></section>
|
||||
<section><h2>Machine Evidence Packet</h2><pre id="packet">-</pre></section>
|
||||
<section><h2>Warnings</h2><pre id="warnings">-</pre></section>
|
||||
</main>
|
||||
<script>
|
||||
const show=(id,value)=>document.getElementById(id).textContent=JSON.stringify(value,null,2);
|
||||
document.getElementById('load').onclick=async()=>{const url=document.getElementById('url').value.trim(),token=document.getElementById('token').value.trim();if(!url||!token)return alert('需要 job URL 与 access token');const r=await fetch(url,{headers:{Authorization:`Bearer ${token}`}});const d=await r.json();if(!r.ok){show('status',d);return}const s=d.execution_status||{};document.getElementById('status').innerHTML=`<span class="badge">${s.official_evidence_status||'unknown'}</span><pre>${JSON.stringify(s,null,2)}</pre>`;show('audit',d.technique_audit||[]);show('packet',d.machine_evidence_packet||{});show('warnings',d.warnings||[])};
|
||||
</script>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Jyotish Consultation</title>
|
||||
<style>body{margin:0;background:#f5f7f8;color:#17212b;font:16px system-ui,-apple-system,"PingFang SC",sans-serif}main{max-width:820px;margin:auto;padding:36px 16px}section{background:#fff;border:1px solid #dce3e5;border-radius:6px;padding:18px;margin:14px 0}a,button{display:inline-block;padding:10px 14px;margin:4px 6px 4px 0;border-radius:4px;background:#006b6b;color:#fff;text-decoration:none;border:0;font:inherit}input{padding:9px;border:1px solid #b6c1c6;border-radius:4px}#location{white-space:pre-wrap}</style>
|
||||
<main><h1>Jyotish Consultation</h1><p>先确认出生资料,再选择直接排盘或主动问询式生时校正。外部引擎状态将在证据包中明示。</p>
|
||||
<section><h2>开始</h2><a href="/rectification">生时不确定:主动问询校正</a><a href="/evidence">查看 Evidence Packet</a></section>
|
||||
<section><h2>出生地点确认</h2><p>可使用本地城市库;未收录时请手填经纬度。此操作不调用第三方地理服务。</p><input id="city" placeholder="城市名称,例如 北京 / Beijing"><button id="resolve">确认坐标</button><div id="location"></div></section>
|
||||
<section><h2>运行环境</h2><button id="doctor">检查 API</button><pre id="status">等待检查</pre></section></main>
|
||||
<script>const out=(id,v)=>document.getElementById(id).textContent=JSON.stringify(v,null,2);document.getElementById('resolve').onclick=async()=>{const city=document.getElementById('city').value.trim();const r=await fetch('/api/location/resolve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({city})});out('location',await r.json())};document.getElementById('doctor').onclick=async()=>{const r=await fetch('/api/health');out('status',await r.json())};</script></html>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>主动问询式生时校正</title>
|
||||
<style>body{margin:0;background:#f5f7f8;color:#17212b;font:15px system-ui,-apple-system,"PingFang SC",sans-serif}main{max-width:860px;margin:auto;padding:28px 16px}form,.question,#result{background:#fff;border:1px solid #dce3e5;border-radius:6px;padding:16px;margin:12px 0}input,button{padding:9px;font:inherit;border:1px solid #b6c1c6;border-radius:4px}button{background:#006b6b;color:#fff;border-color:#006b6b}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.question label{display:block;padding:5px 0}pre{white-space:pre-wrap;word-break:break-word}@media(max-width:600px){.grid{grid-template-columns:1fr}}</style>
|
||||
<main><h1>主动问询式生时校正</h1><p>先扫描候选时间,再回答选择题。结果只缩小候选簇,不宣称已经精确到分钟。</p>
|
||||
<form id="birth"><div class="grid"><input name="year" placeholder="出生年" required><input name="month" placeholder="月" required><input name="day" placeholder="日" required><input name="hour" placeholder="时" required><input name="minute" placeholder="分" required><input name="lat" placeholder="纬度" required><input name="lon" placeholder="经度" required><input name="tz" value="8" placeholder="时区" required><input name="time_uncertainty_minutes" value="30" placeholder="误差分钟"></div><p><button>生成第一轮问题并扫描候选盘</button></p></form><div id="error" role="alert"></div><div id="scan"></div><div id="questions"></div><div id="result"></div></main>
|
||||
<script>
|
||||
let questionnaire;
|
||||
const asObject=f=>Object.fromEntries(new FormData(f).entries());
|
||||
const fail=e=>document.querySelector('#error').textContent=`请求失败:${e.message}。请检查输入后重试。`;
|
||||
async function post(url,body){const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok)throw new Error(d.error||r.status);return d}
|
||||
document.querySelector('#birth').onsubmit=async e=>{e.preventDefault();document.querySelector('#error').textContent='正在计算候选盘…';try{const p=asObject(e.target);for(const k of Object.keys(p))p[k]=Number(p[k]);const [q,scan]=await Promise.all([post('/api/rectification/questionnaire',p),post('/api/rectification/sensitivity_scan',p)]);questionnaire=q;document.querySelector('#error').textContent='';document.querySelector('#scan').innerHTML=`<h2>实际候选盘差异</h2><pre>${JSON.stringify({candidate_count:scan.candidate_count,step_minutes:scan.step_minutes,transitions:scan.transitions,supported_vargas:scan.supported_vargas,unavailable_vargas:scan.unavailable_vargas,pending_layers:scan.pending_layers,boundary:scan.boundary},null,2)}</pre>`;render(questionnaire.questions||[])}catch(err){fail(err)}};
|
||||
function render(qs){const root=document.querySelector('#questions');root.innerHTML=qs.map(q=>`<section class="question"><strong>${q.prompt}</strong>${q.options.map(o=>`<label><input type="radio" name="${q.id}" value="${o.key}"> ${o.key}. ${o.label}</label>`).join('')}</section>`).join('')+'<button id="score">提交本轮答案</button>';document.querySelector('#score').onclick=score}
|
||||
async function score(){try{const answers={};document.querySelectorAll('#questions input:checked').forEach(e=>answers[e.name]=e.value);const d=await post('/api/rectification/answers',{questionnaire,answers});document.querySelector('#result').innerHTML=`<h2>候选簇排序</h2><pre>${JSON.stringify({candidate_cluster_rankings:d.candidate_cluster_rankings,next_round:d.next_round,boundary:d.boundary},null,2)}</pre>`}catch(err){fail(err)}}
|
||||
</script></html>
|
||||
Reference in New Issue
Block a user