mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
feat: proactive agent approval bell with approve/deny UI (#370)
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Bell, CheckCircle, ChevronDown, ChevronUp, Clock, XCircle } from 'lucide-react';
|
||||
import { approveAction, denyAction, fetchPendingApprovals } from '../lib/api';
|
||||
import type { PendingApproval } from '../lib/api';
|
||||
|
||||
const TIER_STYLES: Record<string, { label: string; color: string; bg: string }> = {
|
||||
trivial: { label: 'Trivial', color: 'var(--color-text-secondary)', bg: 'color-mix(in srgb, var(--color-text-secondary) 10%, transparent)' },
|
||||
low: { label: 'Low', color: '#3b82f6', bg: 'rgba(59,130,246,0.12)' },
|
||||
medium: { label: 'Medium', color: 'var(--color-warning)', bg: 'color-mix(in srgb, var(--color-warning) 12%, transparent)' },
|
||||
high: { label: 'High', color: 'var(--color-error)', bg: 'color-mix(in srgb, var(--color-error) 12%, transparent)' },
|
||||
};
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function ApprovalBell() {
|
||||
const [approvals, setApprovals] = useState<PendingApproval[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [processing, setProcessing] = useState<Record<string, boolean>>({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setApprovals(await fetchPendingApprovals());
|
||||
} catch {
|
||||
// backend may not be running yet
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
setProcessing(p => ({ ...p, [id]: true }));
|
||||
try {
|
||||
await approveAction(id);
|
||||
setApprovals(prev => prev.filter(a => a.id !== id));
|
||||
} finally {
|
||||
setProcessing(p => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = async (id: string) => {
|
||||
setProcessing(p => ({ ...p, [id]: true }));
|
||||
try {
|
||||
await denyAction(id);
|
||||
setApprovals(prev => prev.filter(a => a.id !== id));
|
||||
} finally {
|
||||
setProcessing(p => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const count = approvals.length;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="fixed top-2 right-3 z-40">
|
||||
{/* Bell trigger */}
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="relative p-2 rounded-lg transition-colors cursor-pointer"
|
||||
title="Agent approvals"
|
||||
style={{
|
||||
color: count > 0 ? 'var(--color-text)' : 'var(--color-text-secondary)',
|
||||
background: open
|
||||
? 'var(--color-bg-tertiary)'
|
||||
: count > 0
|
||||
? 'color-mix(in srgb, var(--color-error) 8%, transparent)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Bell size={17} />
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 flex items-center justify-center rounded-full text-[10px] font-bold px-1 leading-none"
|
||||
style={{ background: 'var(--color-error)', color: '#fff' }}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-xl shadow-2xl overflow-hidden flex flex-col"
|
||||
style={{
|
||||
width: '340px',
|
||||
maxHeight: '500px',
|
||||
background: 'var(--color-bg-secondary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 shrink-0"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={13} style={{ color: 'var(--color-accent)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Agent Approvals
|
||||
</span>
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="text-[11px] font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||||
color: 'var(--color-error)',
|
||||
}}
|
||||
>
|
||||
{count} pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{count === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<CheckCircle size={26} style={{ color: 'var(--color-text-secondary)', opacity: 0.35 }} />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
No pending approvals
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
approvals.map((action, idx) => {
|
||||
const tier = TIER_STYLES[action.tier] ?? TIER_STYLES.medium;
|
||||
const isExpanded = !!expanded[action.id];
|
||||
const isLoading = !!processing[action.id];
|
||||
const hasPayload = Object.keys(action.payload ?? {}).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="px-4 py-3"
|
||||
style={{
|
||||
borderBottom: idx < count - 1 ? '1px solid var(--color-border)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Row 1: action type + tier + time */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span
|
||||
className="text-[11px] font-mono font-semibold"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
>
|
||||
{action.action_type}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded"
|
||||
style={{ background: tier.bg, color: tier.color }}
|
||||
>
|
||||
{tier.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] flex items-center gap-0.5"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Clock size={9} />
|
||||
{timeAgo(action.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className="text-[13px] mb-2.5 leading-snug"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
>
|
||||
{action.description}
|
||||
</p>
|
||||
|
||||
{/* Expandable payload */}
|
||||
{hasPayload && (
|
||||
<button
|
||||
className="flex items-center gap-1 text-[11px] mb-2 cursor-pointer"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onClick={() =>
|
||||
setExpanded(e => ({ ...e, [action.id]: !e[action.id] }))
|
||||
}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
|
||||
{isExpanded ? 'Hide details' : 'View details'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isExpanded && (
|
||||
<pre
|
||||
className="text-[10px] rounded-lg p-2.5 mb-2.5 overflow-x-auto"
|
||||
style={{
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(action.payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Approve / Deny */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleApprove(action.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs font-semibold transition-opacity cursor-pointer disabled:opacity-40"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
|
||||
color: 'var(--color-success)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-success) 22%, transparent)',
|
||||
}}
|
||||
>
|
||||
<CheckCircle size={12} />
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeny(action.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs font-semibold transition-opacity cursor-pointer disabled:opacity-40"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-error) 12%, transparent)',
|
||||
color: 'var(--color-error)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-error) 22%, transparent)',
|
||||
}}
|
||||
>
|
||||
<XCircle size={12} />
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
import { ApprovalBell } from './ApprovalBell';
|
||||
import { Sidebar } from './Sidebar/Sidebar';
|
||||
import { SystemPulse } from './SystemPulse';
|
||||
import { useAppStore } from '../lib/store';
|
||||
@@ -27,6 +28,7 @@ export function Layout() {
|
||||
<div className="flex flex-col h-full w-full overflow-hidden relative" style={{ paddingTop: '3px' }}>
|
||||
<div className="hud-backdrop" aria-hidden="true" />
|
||||
<SystemPulse apiReachable={apiReachable} />
|
||||
<ApprovalBell />
|
||||
|
||||
{/* Health check banner */}
|
||||
{apiReachable === false && (
|
||||
|
||||
@@ -931,3 +931,36 @@ export async function getMemoryConfig(): Promise<MemoryConfig> {
|
||||
if (!res.ok) throw new Error('Failed to fetch memory config');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approvals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PendingApproval {
|
||||
id: string;
|
||||
action_type: string;
|
||||
description: string;
|
||||
payload: Record<string, unknown>;
|
||||
permission_key: string;
|
||||
tier: 'trivial' | 'low' | 'medium' | 'high';
|
||||
status: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export async function fetchPendingApprovals(): Promise<PendingApproval[]> {
|
||||
const res = await fetch(`${getBase()}/v1/approvals/pending`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.actions || [];
|
||||
}
|
||||
|
||||
export async function approveAction(actionId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/approvals/${actionId}/approve`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function denyAction(actionId: string): Promise<void> {
|
||||
const res = await fetch(`${getBase()}/v1/approvals/${actionId}/deny`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
@@ -883,6 +883,11 @@ async def start_optimize_run(req: OptimizeRunRequest, request: Request):
|
||||
|
||||
def include_all_routes(app) -> None:
|
||||
"""Include all extended API routers in a FastAPI app."""
|
||||
from openjarvis.server.approval_routes import (
|
||||
router as approval_router, # noqa: PLC0415
|
||||
)
|
||||
|
||||
app.include_router(approval_router)
|
||||
app.include_router(agents_router)
|
||||
app.include_router(memory_router)
|
||||
app.include_router(traces_router)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""REST endpoints for the proactive-agent approval queue."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from openjarvis.tools.approval_store import (
|
||||
STATUS_APPROVED,
|
||||
STATUS_DENIED,
|
||||
ApprovalStore,
|
||||
PendingAction,
|
||||
)
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter, HTTPException
|
||||
except ImportError:
|
||||
raise ImportError("fastapi is required for approval routes")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Singleton that shares the same DB file as ProactiveAgent (WAL mode is safe)
|
||||
_store: Optional[ApprovalStore] = None
|
||||
|
||||
|
||||
def _get_store() -> ApprovalStore:
|
||||
global _store
|
||||
if _store is None:
|
||||
_store = ApprovalStore()
|
||||
return _store
|
||||
|
||||
|
||||
def _serialize(action: PendingAction) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": action.id,
|
||||
"action_type": action.action_type,
|
||||
"description": action.description,
|
||||
"payload": action.payload,
|
||||
"permission_key": action.permission_key,
|
||||
"tier": action.tier,
|
||||
"status": action.status,
|
||||
"created_at": action.created_at,
|
||||
"expires_at": action.expires_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/approvals/pending")
|
||||
async def list_pending_approvals() -> Dict[str, Any]:
|
||||
store = _get_store()
|
||||
store.expire_stale()
|
||||
actions = store.list_pending()
|
||||
return {"actions": [_serialize(a) for a in actions], "count": len(actions)}
|
||||
|
||||
|
||||
@router.post("/v1/approvals/{action_id}/approve")
|
||||
async def approve_action(action_id: str) -> Dict[str, Any]:
|
||||
store = _get_store()
|
||||
action = store.get_action(action_id)
|
||||
if action is None:
|
||||
raise HTTPException(status_code=404, detail="Action not found")
|
||||
store.update_status(action_id, STATUS_APPROVED)
|
||||
logger.info("Action %s approved via UI", action_id)
|
||||
return {"status": "approved", "id": action_id}
|
||||
|
||||
|
||||
@router.post("/v1/approvals/{action_id}/deny")
|
||||
async def deny_action(action_id: str) -> Dict[str, Any]:
|
||||
store = _get_store()
|
||||
action = store.get_action(action_id)
|
||||
if action is None:
|
||||
raise HTTPException(status_code=404, detail="Action not found")
|
||||
store.update_status(action_id, STATUS_DENIED)
|
||||
logger.info("Action %s denied via UI", action_id)
|
||||
return {"status": "denied", "id": action_id}
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Tests for the proactive-agent approval queue API routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.tools.approval_store import (
|
||||
STATUS_APPROVED,
|
||||
STATUS_DENIED,
|
||||
STATUS_PENDING,
|
||||
TIER_HIGH,
|
||||
TIER_LOW,
|
||||
TIER_MEDIUM,
|
||||
ApprovalStore,
|
||||
)
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
HAS_FASTAPI = True
|
||||
except ImportError:
|
||||
HAS_FASTAPI = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def approval_store(tmp_path):
|
||||
"""A fresh ApprovalStore backed by a temp DB, injected into the routes module."""
|
||||
import openjarvis.server.approval_routes as ar
|
||||
|
||||
store = ApprovalStore(db_path=str(tmp_path / "approvals.db"))
|
||||
original = ar._store
|
||||
ar._store = store
|
||||
yield store
|
||||
ar._store = original
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(approval_store): # noqa: ARG001 — triggers store injection as a side-effect
|
||||
"""TestClient with the approval router mounted on a minimal FastAPI app."""
|
||||
from openjarvis.server.approval_routes import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _queue(store: ApprovalStore, **kwargs) -> str:
|
||||
"""Helper — queue an action and return its id."""
|
||||
defaults = dict(
|
||||
action_type="file_write",
|
||||
description="Write a report to ~/Desktop/report.txt",
|
||||
payload={"path": "~/Desktop/report.txt", "size_kb": 12},
|
||||
permission_key="file_write:path:~/Desktop",
|
||||
tier=TIER_MEDIUM,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
action = store.queue_action(**defaults)
|
||||
return action.id
|
||||
|
||||
|
||||
def _expire(store: ApprovalStore, action_id: str) -> None:
|
||||
"""Force an action's expires_at into the past so list_pending skips it."""
|
||||
store._conn.execute(
|
||||
"UPDATE pending_actions SET expires_at = ? WHERE id = ?",
|
||||
("2000-01-01T00:00:00+00:00", action_id),
|
||||
)
|
||||
store._conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
|
||||
class TestListPendingApprovals:
|
||||
def test_empty_queue(self, client):
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["actions"] == []
|
||||
assert body["count"] == 0
|
||||
|
||||
def test_returns_queued_items(self, client, approval_store):
|
||||
_queue(approval_store)
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["count"] == 1
|
||||
assert len(body["actions"]) == 1
|
||||
|
||||
def test_returns_multiple_items(self, client, approval_store):
|
||||
_queue(approval_store, action_type="email_send", description="Send digest")
|
||||
_queue(approval_store, action_type="file_delete", description="Delete temps")
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
assert resp.json()["count"] == 2
|
||||
|
||||
def test_response_shape(self, client, approval_store):
|
||||
_queue(
|
||||
approval_store,
|
||||
action_type="sms_send",
|
||||
description="Reply to Alice",
|
||||
payload={"to": "+15550001234", "body": "On my way"},
|
||||
tier=TIER_HIGH,
|
||||
)
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
action = resp.json()["actions"][0]
|
||||
|
||||
required_fields = {
|
||||
"id",
|
||||
"action_type",
|
||||
"description",
|
||||
"payload",
|
||||
"permission_key",
|
||||
"tier",
|
||||
"status",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
}
|
||||
assert required_fields.issubset(action.keys()), (
|
||||
f"Missing fields: {required_fields - action.keys()}"
|
||||
)
|
||||
|
||||
def test_payload_is_dict_not_string(self, client, approval_store):
|
||||
"""The route must deserialize payload — not return the raw JSON string."""
|
||||
_queue(approval_store, payload={"key": "value", "nested": {"x": 1}})
|
||||
action = client.get("/v1/approvals/pending").json()["actions"][0]
|
||||
assert isinstance(action["payload"], dict), (
|
||||
f"Expected dict, got {type(action['payload'])}"
|
||||
)
|
||||
assert action["payload"]["key"] == "value"
|
||||
|
||||
def test_correct_field_values(self, client, approval_store):
|
||||
_queue(
|
||||
approval_store,
|
||||
action_type="calendar_create",
|
||||
description="Create meeting event",
|
||||
tier=TIER_LOW,
|
||||
)
|
||||
action = client.get("/v1/approvals/pending").json()["actions"][0]
|
||||
assert action["action_type"] == "calendar_create"
|
||||
assert action["description"] == "Create meeting event"
|
||||
assert action["tier"] == TIER_LOW
|
||||
assert action["status"] == STATUS_PENDING
|
||||
|
||||
def test_does_not_return_approved_actions(self, client, approval_store):
|
||||
action_id = _queue(approval_store)
|
||||
approval_store.update_status(action_id, STATUS_APPROVED)
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
assert resp.json()["count"] == 0
|
||||
|
||||
def test_does_not_return_denied_actions(self, client, approval_store):
|
||||
action_id = _queue(approval_store)
|
||||
approval_store.update_status(action_id, STATUS_DENIED)
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
assert resp.json()["count"] == 0
|
||||
|
||||
def test_auto_expires_stale_actions(self, client, approval_store):
|
||||
"""GET /pending must expire past-TTL items and exclude them."""
|
||||
action_id = _queue(approval_store)
|
||||
_expire(approval_store, action_id)
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
assert resp.json()["count"] == 0
|
||||
|
||||
def test_stale_expired_mixes_with_live(self, client, approval_store):
|
||||
"""Only expired items are excluded; live items still appear."""
|
||||
stale_id = _queue(approval_store, description="Stale action")
|
||||
_queue(approval_store, description="Live action")
|
||||
_expire(approval_store, stale_id)
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
body = resp.json()
|
||||
assert body["count"] == 1
|
||||
assert body["actions"][0]["description"] == "Live action"
|
||||
|
||||
def test_timestamps_are_strings(self, client, approval_store):
|
||||
_queue(approval_store)
|
||||
action = client.get("/v1/approvals/pending").json()["actions"][0]
|
||||
assert isinstance(action["created_at"], str)
|
||||
assert isinstance(action["expires_at"], str)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
|
||||
class TestApproveAction:
|
||||
def test_approve_returns_200(self, client, approval_store):
|
||||
action_id = _queue(approval_store)
|
||||
resp = client.post(f"/v1/approvals/{action_id}/approve")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_approve_response_body(self, client, approval_store):
|
||||
action_id = _queue(approval_store)
|
||||
body = client.post(f"/v1/approvals/{action_id}/approve").json()
|
||||
assert body["status"] == "approved"
|
||||
assert body["id"] == action_id
|
||||
|
||||
def test_approve_updates_db_status(self, client, approval_store):
|
||||
action_id = _queue(approval_store)
|
||||
client.post(f"/v1/approvals/{action_id}/approve")
|
||||
action = approval_store.get_action(action_id)
|
||||
assert action is not None
|
||||
assert action.status == STATUS_APPROVED
|
||||
|
||||
def test_approve_removes_from_pending_list(self, client, approval_store):
|
||||
action_id = _queue(approval_store)
|
||||
client.post(f"/v1/approvals/{action_id}/approve")
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
ids = [a["id"] for a in resp.json()["actions"]]
|
||||
assert action_id not in ids
|
||||
|
||||
def test_approve_nonexistent_returns_404(self, client):
|
||||
resp = client.post("/v1/approvals/doesnotexist/approve")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_approve_only_removes_target(self, client, approval_store):
|
||||
"""Approving one action must leave others untouched."""
|
||||
id_a = _queue(approval_store, description="Action A")
|
||||
id_b = _queue(approval_store, description="Action B")
|
||||
client.post(f"/v1/approvals/{id_a}/approve")
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
body = resp.json()
|
||||
assert body["count"] == 1
|
||||
assert body["actions"][0]["id"] == id_b
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
|
||||
class TestDenyAction:
|
||||
def test_deny_returns_200(self, client, approval_store):
|
||||
action_id = _queue(approval_store)
|
||||
resp = client.post(f"/v1/approvals/{action_id}/deny")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_deny_response_body(self, client, approval_store):
|
||||
action_id = _queue(approval_store)
|
||||
body = client.post(f"/v1/approvals/{action_id}/deny").json()
|
||||
assert body["status"] == "denied"
|
||||
assert body["id"] == action_id
|
||||
|
||||
def test_deny_updates_db_status(self, client, approval_store):
|
||||
action_id = _queue(approval_store)
|
||||
client.post(f"/v1/approvals/{action_id}/deny")
|
||||
action = approval_store.get_action(action_id)
|
||||
assert action is not None
|
||||
assert action.status == STATUS_DENIED
|
||||
|
||||
def test_deny_removes_from_pending_list(self, client, approval_store):
|
||||
action_id = _queue(approval_store)
|
||||
client.post(f"/v1/approvals/{action_id}/deny")
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
ids = [a["id"] for a in resp.json()["actions"]]
|
||||
assert action_id not in ids
|
||||
|
||||
def test_deny_nonexistent_returns_404(self, client):
|
||||
resp = client.post("/v1/approvals/doesnotexist/deny")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_deny_only_removes_target(self, client, approval_store):
|
||||
id_a = _queue(approval_store, description="Action A")
|
||||
id_b = _queue(approval_store, description="Action B")
|
||||
client.post(f"/v1/approvals/{id_a}/deny")
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
body = resp.json()
|
||||
assert body["count"] == 1
|
||||
assert body["actions"][0]["id"] == id_b
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
|
||||
class TestApprovalStoreIntegration:
|
||||
"""End-to-end scenarios: queue → inspect → decide."""
|
||||
|
||||
def test_full_approve_flow(self, client, approval_store):
|
||||
"""Queue an action, verify it appears, approve it, verify it's gone."""
|
||||
action_id = _queue(
|
||||
approval_store,
|
||||
action_type="email_delete",
|
||||
description="Delete promotional email from newsletters@company.com",
|
||||
payload={"message_id": "abc123", "subject": "Weekly promo"},
|
||||
tier=TIER_HIGH,
|
||||
)
|
||||
|
||||
# Should appear in pending
|
||||
pending = client.get("/v1/approvals/pending").json()
|
||||
assert pending["count"] == 1
|
||||
assert pending["actions"][0]["id"] == action_id
|
||||
|
||||
# Approve it
|
||||
resp = client.post(f"/v1/approvals/{action_id}/approve")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Should be gone from pending
|
||||
assert client.get("/v1/approvals/pending").json()["count"] == 0
|
||||
|
||||
# Status in DB should be approved
|
||||
assert approval_store.get_action(action_id).status == STATUS_APPROVED
|
||||
|
||||
def test_full_deny_flow(self, client, approval_store):
|
||||
action_id = _queue(
|
||||
approval_store,
|
||||
action_type="file_delete",
|
||||
description="Delete ~/Documents/report.pdf",
|
||||
tier=TIER_HIGH,
|
||||
)
|
||||
|
||||
assert client.get("/v1/approvals/pending").json()["count"] == 1
|
||||
|
||||
resp = client.post(f"/v1/approvals/{action_id}/deny")
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert client.get("/v1/approvals/pending").json()["count"] == 0
|
||||
assert approval_store.get_action(action_id).status == STATUS_DENIED
|
||||
|
||||
def test_mixed_queue_approve_one_deny_one(self, client, approval_store):
|
||||
id_a = _queue(approval_store, description="Send email")
|
||||
id_b = _queue(approval_store, description="Delete file")
|
||||
id_c = _queue(approval_store, description="Create calendar event")
|
||||
|
||||
assert client.get("/v1/approvals/pending").json()["count"] == 3
|
||||
|
||||
client.post(f"/v1/approvals/{id_a}/approve")
|
||||
client.post(f"/v1/approvals/{id_b}/deny")
|
||||
|
||||
pending = client.get("/v1/approvals/pending").json()
|
||||
assert pending["count"] == 1
|
||||
assert pending["actions"][0]["id"] == id_c
|
||||
|
||||
assert approval_store.get_action(id_a).status == STATUS_APPROVED
|
||||
assert approval_store.get_action(id_b).status == STATUS_DENIED
|
||||
assert approval_store.get_action(id_c).status == STATUS_PENDING
|
||||
|
||||
def test_empty_payload_handled(self, client, approval_store):
|
||||
"""Actions with empty payloads should serialize cleanly."""
|
||||
_queue(approval_store, payload={})
|
||||
action = client.get("/v1/approvals/pending").json()["actions"][0]
|
||||
assert isinstance(action["payload"], dict)
|
||||
assert action["payload"] == {}
|
||||
|
||||
def test_nested_payload_preserved(self, client, approval_store):
|
||||
payload = {
|
||||
"email": {"to": "user@example.com", "subject": "Hello"},
|
||||
"metadata": {"priority": 1, "tags": ["newsletter", "promo"]},
|
||||
}
|
||||
_queue(approval_store, payload=payload)
|
||||
action = client.get("/v1/approvals/pending").json()["actions"][0]
|
||||
assert action["payload"]["email"]["to"] == "user@example.com"
|
||||
assert action["payload"]["metadata"]["tags"] == ["newsletter", "promo"]
|
||||
|
||||
def test_all_tiers_accepted(self, client, approval_store):
|
||||
for tier in ["trivial", "low", "medium", "high"]:
|
||||
_queue(approval_store, tier=tier, description=f"{tier} action")
|
||||
resp = client.get("/v1/approvals/pending")
|
||||
tiers = {a["tier"] for a in resp.json()["actions"]}
|
||||
assert tiers == {"trivial", "low", "medium", "high"}
|
||||
Reference in New Issue
Block a user