Fix audit findings and CI test failures

Backend fixes:
- auth.rs: Replace .unwrap() with pattern matching and .expect()
- middleware.rs: Replace .unwrap() with .expect() for static headers
- pipeline.rs: Add error logging for analysis/planning failures
- pipeline.rs: Log profile fetch errors instead of swallowing silently
- wizard.rs: Set setup_required=false after setup completes (fixes CI)
- tests.rs: Use tx_capacity for jobs channel to test lag behavior (fixes CI)

Frontend fixes:
- JobManager.tsx: Add exponential backoff for SSE reconnect (1s-30s + jitter)
- SettingsPanel.tsx: Fix React hydration by moving URL parsing to useEffect
- api.ts: Increase timeout from 15s to 30s for hardware detection

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-03-30 11:08:23 -04:00
parent d17c8dbe23
commit 69902b8517
8 changed files with 72 additions and 28 deletions

View File

@@ -590,6 +590,7 @@ impl Pipeline {
Ok(a) => a,
Err(e) => {
let reason = format!("analysis_failed|error={e}");
let _ = self.db.add_log("error", Some(job_id), &reason).await;
self.db.add_decision(job_id, "skip", &reason).await.ok();
self.db
.update_job_status(job_id, crate::db::JobState::Failed)
@@ -602,11 +603,13 @@ impl Pipeline {
let output_path = std::path::PathBuf::from(&job.output_path);
// Get profile for this job's input path (if any)
let profile = self
.db
.get_profile_for_path(&job.input_path)
.await
.unwrap_or(None);
let profile = match self.db.get_profile_for_path(&job.input_path).await {
Ok(p) => p,
Err(e) => {
tracing::warn!("Failed to fetch profile for {}: {}", job.input_path, e);
None
}
};
// Run the planner
let config_snapshot = Arc::new(self.config.read().await.clone());
@@ -619,6 +622,7 @@ impl Pipeline {
Ok(p) => p,
Err(e) => {
let reason = format!("planning_failed|error={e}");
let _ = self.db.add_log("error", Some(job_id), &reason).await;
self.db.add_decision(job_id, "skip", &reason).await.ok();
self.db
.update_job_status(job_id, crate::db::JobState::Failed)

View File

@@ -44,11 +44,11 @@ pub(crate) async fn login_handler(
let parsed_hash = match &user_result {
Some(u) => PasswordHash::new(&u.password_hash).unwrap_or_else(|_| {
is_valid = false;
PasswordHash::new(DUMMY_HASH).unwrap()
PasswordHash::new(DUMMY_HASH).expect("DUMMY_HASH must be a valid argon2 hash")
}),
None => {
is_valid = false;
PasswordHash::new(DUMMY_HASH).unwrap()
PasswordHash::new(DUMMY_HASH).expect("DUMMY_HASH must be a valid argon2 hash")
}
};
@@ -59,11 +59,9 @@ pub(crate) async fn login_handler(
is_valid = false;
}
if !is_valid || user_result.is_none() {
let Some(user) = user_result.filter(|_| is_valid) else {
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
}
let user = user_result.unwrap();
};
// Create session
let token: String = rand::rng()

View File

@@ -29,17 +29,23 @@ pub(crate) async fn security_headers_middleware(request: Request, next: Next) ->
let headers = response.headers_mut();
// Prevent clickjacking
headers.insert(header::X_FRAME_OPTIONS, "DENY".parse().unwrap());
headers.insert(
header::X_FRAME_OPTIONS,
"DENY".parse().expect("valid header value"),
);
// Prevent MIME type sniffing
headers.insert(header::X_CONTENT_TYPE_OPTIONS, "nosniff".parse().unwrap());
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
"nosniff".parse().expect("valid header value"),
);
// XSS protection (legacy but still useful)
headers.insert(
"X-XSS-Protection"
.parse::<axum::http::HeaderName>()
.unwrap(),
"1; mode=block".parse().unwrap(),
.expect("valid header name"),
"1; mode=block".parse().expect("valid header value"),
);
// Content Security Policy - allows inline scripts/styles for the SPA
@@ -48,21 +54,25 @@ pub(crate) async fn security_headers_middleware(request: Request, next: Next) ->
header::CONTENT_SECURITY_POLICY,
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'"
.parse()
.unwrap(),
.expect("valid CSP header value"),
);
// Referrer policy
headers.insert(
header::REFERRER_POLICY,
"strict-origin-when-cross-origin".parse().unwrap(),
"strict-origin-when-cross-origin"
.parse()
.expect("valid header value"),
);
// Permissions policy (restrict browser features)
headers.insert(
"Permissions-Policy"
.parse::<axum::http::HeaderName>()
.unwrap(),
"geolocation=(), microphone=(), camera=()".parse().unwrap(),
.expect("valid header name"),
"geolocation=(), microphone=(), camera=()"
.parse()
.expect("valid header value"),
);
response

View File

@@ -61,7 +61,8 @@ where
let transcoder = Arc::new(Transcoder::new());
// Create event channels before Agent
let (jobs_tx, _) = broadcast::channel(1000);
// Use tx_capacity for jobs channel to allow testing lag behavior
let (jobs_tx, _) = broadcast::channel(tx_capacity);
let (config_tx, _) = broadcast::channel(50);
let (system_tx, _) = broadcast::channel(100);
let event_channels = Arc::new(crate::db::EventChannels {

View File

@@ -282,6 +282,11 @@ pub(crate) async fn setup_complete_handler(
replace_runtime_hardware(state.as_ref(), hardware_info, probe_log).await;
refresh_file_watcher(&state).await;
// Mark setup as complete
state
.setup_required
.store(false, std::sync::atomic::Ordering::Relaxed);
// Start Scan (optional, but good for UX)
// Use library_scanner so the UI can track progress via /api/scan/status
let scanner = state.library_scanner.clone();

View File

@@ -139,7 +139,7 @@ export function humanizeSkipReason(reason: string): SkipDetail {
default:
return {
summary: "Skipped",
summary: "Decision recorded",
detail: reason,
action: null,
measured,
@@ -379,12 +379,28 @@ export default function JobManager() {
let eventSource: EventSource | null = null;
let cancelled = false;
let reconnectTimeout: number | null = null;
let reconnectAttempts = 0;
const getReconnectDelay = () => {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
const baseDelay = 1000;
const maxDelay = 30000;
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts), maxDelay);
// Add jitter (±25%) to prevent thundering herd
const jitter = delay * 0.25 * (Math.random() * 2 - 1);
return Math.round(delay + jitter);
};
const connect = () => {
if (cancelled) return;
eventSource?.close();
eventSource = new EventSource("/api/events");
eventSource.onopen = () => {
// Reset reconnect attempts on successful connection
reconnectAttempts = 0;
};
eventSource.addEventListener("status", (e) => {
try {
const { job_id, status } = JSON.parse(e.data) as {
@@ -425,7 +441,9 @@ export default function JobManager() {
eventSource.onerror = () => {
eventSource?.close();
if (!cancelled) {
reconnectTimeout = window.setTimeout(connect, 3000);
reconnectAttempts++;
const delay = getReconnectDelay();
reconnectTimeout = window.setTimeout(connect, delay);
}
};
};
@@ -1281,7 +1299,9 @@ export default function JobManager() {
{focusedDecision && (
<div className="space-y-3">
<p className="text-sm font-medium text-helios-ink">
{focusedDecision.summary}
{focusedJob.job.status === "completed"
? "Transcoded"
: focusedDecision.summary}
</p>
<p className="text-xs leading-relaxed text-helios-slate">
{focusedDecision.detail}

View File

@@ -25,12 +25,17 @@ const TABS = [
];
export default function SettingsPanel() {
const [activeTab, setActiveTab] = useState(() => {
if (typeof window === "undefined") return "watch";
// Start with default tab to avoid hydration mismatch, then sync with URL in useEffect
const [activeTab, setActiveTab] = useState("watch");
// Sync tab state from URL after hydration (client-side only)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const requested = params.get("tab");
return requested && TABS.some((tab) => tab.id === requested) ? requested : "watch";
});
if (requested && TABS.some((tab) => tab.id === requested)) {
setActiveTab(requested);
}
}, []);
const activeIndex = TABS.findIndex(t => t.id === activeTab);

View File

@@ -90,7 +90,8 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
}
const controller = new AbortController();
const timeoutMs = 15000;
// 30s timeout: hardware detection and large scans can take time
const timeoutMs = 30000;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const abortHandler = () => controller.abort();