diff --git a/backend/secuscan/utils/__init__.py b/backend/secuscan/utils/__init__.py new file mode 100644 index 00000000..06653405 --- /dev/null +++ b/backend/secuscan/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for SecuScan.""" diff --git a/backend/secuscan/utils/scheduler.py b/backend/secuscan/utils/scheduler.py new file mode 100644 index 00000000..846a977b --- /dev/null +++ b/backend/secuscan/utils/scheduler.py @@ -0,0 +1,183 @@ +""" +Scheduling engine for recurring scans with timezone support, blackout windows, +and missed-run recovery. +""" + +from datetime import datetime +from zoneinfo import ZoneInfo +from croniter import croniter + + +def get_next_run_time(cron_expr: str, tz_string: str, base_time: datetime = None) -> datetime: + """ + Calculates the next run time respecting the operator's timezone. + + Args: + cron_expr: Standard 5-part cron expression (minute, hour, day, month, day-of-week) + tz_string: IANA timezone string (e.g., 'UTC', 'America/New_York', 'Asia/Kolkata') + base_time: Optional starting point; defaults to current time in the specified timezone + + Returns: + Next valid run time as a timezone-aware datetime object + + Raises: + ValueError: If cron expression or timezone is invalid + """ + try: + tz = ZoneInfo(tz_string) + except Exception as e: + raise ValueError(f"Invalid timezone '{tz_string}': {e}") + + if not base_time: + base_time = datetime.now(tz) + else: + # Ensure base_time is timezone-aware + if base_time.tzinfo is None: + base_time = base_time.replace(tzinfo=tz) + else: + base_time = base_time.astimezone(tz) + + try: + cron = croniter(cron_expr, base_time) + return cron.get_next(datetime) + except Exception as e: + raise ValueError(f"Invalid cron expression '{cron_expr}': {e}") + + +def is_in_blackout_window( + current_time: datetime, + blackout_start: str, + blackout_end: str +) -> bool: + """ + Evaluates if current_time falls between blackout_start and blackout_end. + + Handles overnight windows (e.g., 23:00 to 02:00) correctly. + + Args: + current_time: Timezone-aware datetime to check + blackout_start: Time string in 'HH:MM' format (e.g., '22:00') + blackout_end: Time string in 'HH:MM' format (e.g., '06:00') + + Returns: + True if current_time is within the blackout window; False otherwise + """ + if not blackout_start or not blackout_end: + return False + + try: + # Extract time from current_time in its timezone + current_time_str = current_time.strftime("%H:%M") + + # Parse start and end times + start_hour, start_min = map(int, blackout_start.split(':')) + end_hour, end_min = map(int, blackout_end.split(':')) + + current_hour, current_min = map(int, current_time_str.split(':')) + + # Convert times to minutes for easier comparison + current_minutes = current_hour * 60 + current_min + start_minutes = start_hour * 60 + start_min + end_minutes = end_hour * 60 + end_min + + # Overnight window (e.g., 23:00 to 02:00) + if start_minutes > end_minutes: + return current_minutes >= start_minutes or current_minutes < end_minutes + # Same-day window (e.g., 14:00 to 18:00) + else: + return start_minutes <= current_minutes < end_minutes + + except (ValueError, AttributeError): + # Invalid format; treat as no blackout + return False + + +def should_recover_missed_run( + last_run_time: datetime, + cron_expr: str, + tz_string: str, + blackout_start: str = None, + blackout_end: str = None +) -> bool: + """ + Determines if a missed scan should execute immediately upon system recovery. + + Recovery logic: + - If the expected next run is in the past AND not currently in a blackout window, + the scan should execute immediately to catch up. + - If the expected next run hasn't arrived yet, wait. + - If the expected next run is in a blackout window, skip until next cycle. + + Args: + last_run_time: Timezone-aware datetime of the last successful scan + cron_expr: Cron expression for the recurring scan + tz_string: IANA timezone string + blackout_start: Optional blackout window start time ('HH:MM') + blackout_end: Optional blackout window end time ('HH:MM') + + Returns: + True if the missed scan should be recovered; False otherwise + """ + try: + tz = ZoneInfo(tz_string) + now = datetime.now(tz) + + # Calculate what the next expected run was after the last successful run + expected_run = get_next_run_time(cron_expr, tz_string, last_run_time) + + # If the expected run is still in the future, don't recover yet + if expected_run > now: + return False + + # If the expected run is in the past, check if we're currently in a blackout window + if is_in_blackout_window(now, blackout_start or "", blackout_end or ""): + # We're in a blackout; don't execute + return False + + # Expected run was missed and we're not in blackout; recover + return True + + except Exception: + # On any error, default to no recovery to avoid double-execution + return False + + +def validate_cron_expression(cron_expr: str) -> bool: + """ + Validates that a cron expression has exactly 5 parts. + + Args: + cron_expr: Cron expression string + + Returns: + True if valid 5-part cron; False otherwise + """ + try: + parts = cron_expr.strip().split() + # Standard cron has 5 parts; extended may have 6 (with seconds), but we enforce 5 + return len(parts) == 5 and croniter.is_valid(cron_expr) + except Exception: + return False + + +def validate_time_format(time_str: str) -> bool: + """ + Validates that a time string is in 'HH:MM' format and represents a valid time. + + Args: + time_str: Time string to validate + + Returns: + True if valid 'HH:MM' format; False otherwise + """ + if not time_str: + return True # Empty is valid (no blackout) + + try: + parts = time_str.split(':') + if len(parts) != 2: + return False + hour, minute = int(parts[0]), int(parts[1]) + return 0 <= hour < 24 and 0 <= minute < 60 + except (ValueError, AttributeError): + return False diff --git a/frontend/src/components/ScanScheduleForm.jsx b/frontend/src/components/ScanScheduleForm.jsx new file mode 100644 index 00000000..9c443472 --- /dev/null +++ b/frontend/src/components/ScanScheduleForm.jsx @@ -0,0 +1,304 @@ +import React, { useState } from 'react'; +import styles from './ScanScheduleForm.module.css'; + +/** + * ScanScheduleForm Component + * + * A comprehensive form for configuring recurring scheduled scans with: + * - Cron expression input with 5-part validation + * - Timezone selection with IANA timezone support + * - Optional blackout window configuration (time ranges to skip scans) + * - Client-side validation before submission + */ +const ScanScheduleForm = ({ onSubmit, onCancel }) => { + // Form state + const [cronExpr, setCronExpr] = useState('0 2 * * *'); // Default: 2 AM daily + const [timezone, setTimezone] = useState('UTC'); + const [blackoutStart, setBlackoutStart] = useState(''); + const [blackoutEnd, setBlackoutEnd] = useState(''); + const [error, setError] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Timezone list (common timezones for quick access) + const commonTimezones = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Asia/Kolkata', + 'Asia/Bangkok', + 'Asia/Tokyo', + 'Australia/Sydney', + 'Australia/Melbourne' + ]; + + /** + * Validates cron expression: must contain exactly 5 space-separated parts + */ + const validateCron = (cron) => { + if (!cron || !cron.trim()) { + return { valid: false, message: 'Cron expression is required.' }; + } + + const parts = cron.trim().split(/\s+/); + if (parts.length !== 5) { + return { + valid: false, + message: `Invalid cron expression. Must contain exactly 5 fields, found ${parts.length}. Format: minute hour day month day-of-week (e.g., "0 2 * * *").` + }; + } + + // Basic validation: check if each part is non-empty + if (parts.some(part => !part)) { + return { valid: false, message: 'Cron expression contains empty fields.' }; + } + + return { valid: true }; + }; + + /** + * Validates time format (HH:MM) + */ + const validateTimeFormat = (timeStr) => { + if (!timeStr) { + return true; // Empty is valid + } + + const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/; + return timeRegex.test(timeStr); + }; + + /** + * Validates blackout window configuration + */ + const validateBlackoutWindow = () => { + const hasStart = blackoutStart && blackoutStart.trim(); + const hasEnd = blackoutEnd && blackoutEnd.trim(); + + // Both or neither should be provided + if ((hasStart && !hasEnd) || (!hasStart && hasEnd)) { + return { + valid: false, + message: 'Both start and end times must be provided for a blackout window.' + }; + } + + // Validate time format + if (hasStart && !validateTimeFormat(blackoutStart)) { + return { + valid: false, + message: 'Blackout start time must be in HH:MM format (e.g., 22:00).' + }; + } + + if (hasEnd && !validateTimeFormat(blackoutEnd)) { + return { + valid: false, + message: 'Blackout end time must be in HH:MM format (e.g., 06:00).' + }; + } + + return { valid: true }; + }; + + /** + * Handles form submission with comprehensive validation + */ + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setSuccessMessage(''); + + // Validate cron expression + const cronValidation = validateCron(cronExpr); + if (!cronValidation.valid) { + setError(cronValidation.message); + return; + } + + // Validate blackout window + const blackoutValidation = validateBlackoutWindow(); + if (!blackoutValidation.valid) { + setError(blackoutValidation.message); + return; + } + + // Prepare payload + const payload = { + cron_expression: cronExpr.trim(), + timezone: timezone, + blackout_start: blackoutStart || null, + blackout_end: blackoutEnd || null + }; + + setIsSubmitting(true); + + try { + // Call the provided onSubmit callback + if (typeof onSubmit === 'function') { + await onSubmit(payload); + setSuccessMessage('Schedule saved successfully!'); + + // Reset form after successful submission + setCronExpr('0 2 * * *'); + setTimezone('UTC'); + setBlackoutStart(''); + setBlackoutEnd(''); + } + } catch (err) { + setError(err.message || 'Failed to save schedule. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

Configure Recurring Scan

+

+ Set up automated periodic scans with timezone support and optional blackout windows +

+
+ + {/* Error Message */} + {error && ( +
+ ⚠️ + {error} +
+ )} + + {/* Success Message */} + {successMessage && ( +
+ + {successMessage} +
+ )} + + {/* Cron Expression Input */} +
+ + setCronExpr(e.target.value)} + placeholder="0 2 * * *" + required + spellCheck="false" + aria-describedby="cron-help" + /> +
+ Standard 5-part cron syntax: minute hour day month day-of-week +
+ Examples: 0 2 * * * (daily at 2 AM) • 0 */6 * * * (every 6 hours) • 0 0 * * MON (Mondays at midnight) +
+
+ + {/* Timezone Selection */} +
+ + +
+ All cron times and blackout windows are evaluated in this timezone +
+
+ + {/* Blackout Window Configuration */} +
+ +

+ Scans scheduled during this time window will be automatically skipped. Useful for maintenance windows or business hours. +

+
+
+ + setBlackoutStart(e.target.value)} + aria-describedby="blackout-start-help" + /> +
+ HH:MM format (24-hour) +
+
+ +
to
+ +
+ + setBlackoutEnd(e.target.value)} + aria-describedby="blackout-end-help" + /> +
+ HH:MM format (24-hour). Windows that cross midnight (e.g., 23:00–06:00) are supported. +
+
+
+
+ + {/* Form Actions */} +
+ + {onCancel && ( + + )} +
+
+ ); +}; + +export default ScanScheduleForm; diff --git a/frontend/src/components/ScanScheduleForm.module.css b/frontend/src/components/ScanScheduleForm.module.css new file mode 100644 index 00000000..21bc699c --- /dev/null +++ b/frontend/src/components/ScanScheduleForm.module.css @@ -0,0 +1,371 @@ +/* ScanScheduleForm.module.css */ + +.container { + max-width: 600px; + margin: 0 auto; + padding: 2rem; + border-radius: 8px; + background-color: var(--background-secondary, #f8f9fa); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +.header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 2px solid var(--border-color, #e0e0e0); +} + +.header h2 { + margin: 0 0 0.5rem 0; + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary, #1a1a1a); +} + +.subtitle { + margin: 0; + font-size: 0.95rem; + color: var(--text-secondary, #666); + line-height: 1.5; +} + +/* Alerts */ +.errorAlert, +.successAlert { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + margin-bottom: 1.5rem; + border-radius: 6px; + font-size: 0.95rem; + line-height: 1.5; + animation: slideIn 0.3s ease-out; +} + +.errorAlert { + background-color: #fee; + border: 1px solid #fcc; + color: #c33; +} + +.successAlert { + background-color: #efe; + border: 1px solid #cfc; + color: #3c3; +} + +.errorIcon, +.successIcon { + font-size: 1.2rem; + flex-shrink: 0; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Form Groups */ +.formGroup { + margin-bottom: 2rem; +} + +.formGroup:last-of-type { + margin-bottom: 0; +} + +.label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +.required { + color: #e74c3c; + margin-left: 0.25rem; +} + +.optional { + color: var(--text-secondary, #666); + font-weight: 400; + font-size: 0.9rem; +} + +/* Input Fields */ +.input, +.select, +.timeField { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border-color, #d0d0d0); + border-radius: 4px; + font-size: 1rem; + font-family: inherit; + background-color: var(--background-primary, #fff); + color: var(--text-primary, #1a1a1a); + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box; +} + +.input:focus, +.select:focus, +.timeField:focus { + outline: none; + border-color: var(--primary-color, #007bff); + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); +} + +.input::placeholder { + color: var(--text-tertiary, #999); +} + +.select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.5rem; +} + +/* Help Text */ +.helpText { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary, #666); + line-height: 1.4; +} + +.helpText code { + display: inline-block; + background-color: var(--code-background, #f0f0f0); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.9rem; + color: var(--code-color, #d32f2f); + white-space: nowrap; +} + +/* Description Text */ +.description { + margin: 0.5rem 0 1rem 0; + font-size: 0.9rem; + color: var(--text-secondary, #666); + line-height: 1.5; +} + +/* Time Range Container */ +.timeRangeContainer { + display: flex; + align-items: flex-end; + gap: 1rem; + margin-top: 1rem; +} + +.timeInput { + flex: 1; + display: flex; + flex-direction: column; +} + +.timeLabel { + display: block; + margin-bottom: 0.5rem; + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +.timeField { + padding: 0.65rem; + font-size: 0.95rem; +} + +.separator { + display: none; + margin: 0 0.5rem; + color: var(--text-secondary, #666); + font-weight: 500; +} + +@media (min-width: 480px) { + .timeRangeContainer { + gap: 0.75rem; + } + + .separator { + display: block; + margin-bottom: 0.25rem; + } +} + +/* Form Actions */ +.formActions { + display: flex; + gap: 1rem; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color, #e0e0e0); +} + +.submitButton, +.cancelButton { + flex: 1; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; +} + +.submitButton { + background-color: var(--primary-color, #007bff); + color: #fff; +} + +.submitButton:hover:not(:disabled) { + background-color: var(--primary-dark, #0056b3); + box-shadow: 0 4px 8px rgba(0, 86, 179, 0.2); +} + +.submitButton:active:not(:disabled) { + transform: scale(0.98); +} + +.submitButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.cancelButton { + background-color: var(--background-tertiary, #e8e8e8); + color: var(--text-primary, #1a1a1a); +} + +.cancelButton:hover:not(:disabled) { + background-color: var(--background-secondary, #d8d8d8); +} + +.cancelButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Responsive Design */ +@media (max-width: 640px) { + .container { + padding: 1.5rem 1rem; + max-width: 100%; + } + + .header h2 { + font-size: 1.5rem; + } + + .header { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + } + + .formGroup { + margin-bottom: 1.5rem; + } + + .timeRangeContainer { + flex-direction: column; + gap: 1.5rem; + } + + .separator { + display: none !important; + } + + .formActions { + flex-direction: column; + } + + .submitButton, + .cancelButton { + width: 100%; + } +} + +/* Dark mode support (if your app uses prefers-color-scheme) */ +@media (prefers-color-scheme: dark) { + .container { + background-color: #2a2a2a; + } + + .header { + border-bottom-color: #444; + } + + .header h2 { + color: #e8e8e8; + } + + .subtitle { + color: #aaa; + } + + .label { + color: #e8e8e8; + } + + .input, + .select, + .timeField { + background-color: #3a3a3a; + border-color: #555; + color: #e8e8e8; + } + + .input:focus, + .select:focus, + .timeField:focus { + border-color: #66b3ff; + box-shadow: 0 0 0 3px rgba(102, 179, 255, 0.2); + } + + .helpText, + .description, + .separator { + color: #aaa; + } + + .helpText code { + background-color: #1a1a1a; + color: #ff6b6b; + } + + .timeLabel { + color: #e8e8e8; + } + + .formActions { + border-top-color: #444; + } + + .cancelButton { + background-color: #444; + color: #e8e8e8; + } + + .cancelButton:hover:not(:disabled) { + background-color: #555; + } +} diff --git a/frontend/src/components/ScanScheduleForm.test.jsx b/frontend/src/components/ScanScheduleForm.test.jsx new file mode 100644 index 00000000..920c020f --- /dev/null +++ b/frontend/src/components/ScanScheduleForm.test.jsx @@ -0,0 +1,398 @@ +/** + * Tests for ScanScheduleForm component + * + * Uses React Testing Library for modern, user-centric testing + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ScanScheduleForm from './ScanScheduleForm'; +import '@testing-library/jest-dom'; + +describe('ScanScheduleForm Component', () => { + let mockOnSubmit; + let mockOnCancel; + + beforeEach(() => { + mockOnSubmit = jest.fn().mockResolvedValue(undefined); + mockOnCancel = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + test('renders form with all required fields', () => { + render(); + + expect(screen.getByText('Configure Recurring Scan')).toBeInTheDocument(); + expect(screen.getByLabelText(/Cron Expression/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Timezone/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Blackout Window/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Save Schedule/i })).toBeInTheDocument(); + }); + + test('renders with default values', () => { + render(); + + expect(screen.getByDisplayValue('0 2 * * *')).toBeInTheDocument(); + expect(screen.getByDisplayValue('UTC')).toBeInTheDocument(); + }); + + test('renders cancel button when onCancel prop is provided', () => { + render(); + + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); + }); + + test('renders helpful documentation text', () => { + render(); + + expect(screen.getByText(/5-part cron syntax/i)).toBeInTheDocument(); + expect(screen.getByText(/IANA timezone/i)).toBeInTheDocument(); + expect(screen.getByText(/24-hour/i)).toBeInTheDocument(); + }); + }); + + describe('Cron Expression Validation', () => { + test('accepts valid cron expressions', async () => { + const user = userEvent.setup(); + render(); + + const cronInput = screen.getByDisplayValue('0 2 * * *'); + await user.clear(cronInput); + await user.type(cronInput, '0 */6 * * *'); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + cron_expression: '0 */6 * * *', + }) + ); + }); + }); + + test('rejects cron with insufficient parts', async () => { + const user = userEvent.setup(); + render(); + + const cronInput = screen.getByDisplayValue('0 2 * * *'); + await user.clear(cronInput); + await user.type(cronInput, '0 2 *'); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + expect(screen.getByText(/exactly 5 fields/i)).toBeInTheDocument(); + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + test('rejects cron with too many parts', async () => { + const user = userEvent.setup(); + render(); + + const cronInput = screen.getByDisplayValue('0 2 * * *'); + await user.clear(cronInput); + await user.type(cronInput, '0 2 * * * *'); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + expect(screen.getByText(/exactly 5 fields/i)).toBeInTheDocument(); + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + test('rejects empty cron expression', async () => { + const user = userEvent.setup(); + render(); + + const cronInput = screen.getByDisplayValue('0 2 * * *'); + await user.clear(cronInput); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + expect(screen.getByText(/required/i)).toBeInTheDocument(); + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + }); + + describe('Blackout Window Validation', () => { + test('allows blackout window with both start and end times', async () => { + const user = userEvent.setup(); + render(); + + const startTimeInput = screen.getAllByRole('textbox').find( + el => el.getAttribute('type') === 'time' && el.id === 'blackout-start' + ) || screen.getByDisplayValue(''); + + // Since time inputs are HTML5, we need to set them properly + const timeInputs = screen.getAllByRole('textbox').filter(el => el.type === 'time'); + + if (timeInputs.length >= 2) { + await user.type(timeInputs[0], '22:00'); + await user.type(timeInputs[1], '06:00'); + } + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalled(); + }); + }); + + test('rejects blackout with only start time', async () => { + const user = userEvent.setup(); + render(); + + const timeInputs = screen.getAllByRole('textbox').filter(el => el.type === 'time'); + + if (timeInputs.length >= 1) { + // Set only start time + fireEvent.change(timeInputs[0], { target: { value: '22:00' } }); + } + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + expect(screen.getByText(/Both start and end times must be provided/i)).toBeInTheDocument(); + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + test('rejects blackout with only end time', async () => { + const user = userEvent.setup(); + render(); + + const timeInputs = screen.getAllByRole('textbox').filter(el => el.type === 'time'); + + if (timeInputs.length >= 2) { + // Set only end time + fireEvent.change(timeInputs[1], { target: { value: '06:00' } }); + } + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + expect(screen.getByText(/Both start and end times must be provided/i)).toBeInTheDocument(); + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + test('allows empty blackout window', async () => { + const user = userEvent.setup(); + render(); + + // Don't set any blackout times, just submit + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + blackout_start: null, + blackout_end: null, + }) + ); + }); + }); + }); + + describe('Timezone Selection', () => { + test('allows changing timezone', async () => { + const user = userEvent.setup(); + render(); + + const tzSelect = screen.getByDisplayValue('UTC'); + await user.selectOptions(tzSelect, 'America/New_York'); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + timezone: 'America/New_York', + }) + ); + }); + }); + + test('includes common timezones in the dropdown', () => { + render(); + + const tzSelect = screen.getByDisplayValue('UTC'); + expect(tzSelect).toHaveDisplayValue('UTC'); + + // Verify some common timezones are available + const options = Array.from(tzSelect.querySelectorAll('option')).map(opt => opt.value); + expect(options).toContain('America/New_York'); + expect(options).toContain('Asia/Kolkata'); + expect(options).toContain('Europe/London'); + }); + }); + + describe('Form Submission', () => { + test('calls onSubmit with correct payload', async () => { + const user = userEvent.setup(); + render(); + + const cronInput = screen.getByDisplayValue('0 2 * * *'); + const tzSelect = screen.getByDisplayValue('UTC'); + + await user.clear(cronInput); + await user.type(cronInput, '0 12 * * *'); + await user.selectOptions(tzSelect, 'Asia/Kolkata'); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith({ + cron_expression: '0 12 * * *', + timezone: 'Asia/Kolkata', + blackout_start: null, + blackout_end: null, + }); + }); + }); + + test('disables submit button while submitting', async () => { + const user = userEvent.setup(); + mockOnSubmit.mockImplementation( + () => new Promise(resolve => setTimeout(resolve, 100)) + ); + + render(); + + const submitButton = screen.getByRole('button', { name: /Save Schedule/i }); + + await user.click(submitButton); + + expect(submitButton).toBeDisabled(); + }); + + test('displays success message after successful submission', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + await waitFor(() => { + expect(screen.getByText(/Schedule saved successfully/i)).toBeInTheDocument(); + }); + }); + + test('resets form after successful submission', async () => { + const user = userEvent.setup(); + render(); + + const cronInput = screen.getByDisplayValue('0 2 * * *'); + await user.clear(cronInput); + await user.type(cronInput, '0 12 * * *'); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + await waitFor(() => { + expect(cronInput).toHaveDisplayValue('0 2 * * *'); + }); + }); + }); + + describe('Error Handling', () => { + test('displays error message on submission failure', async () => { + const user = userEvent.setup(); + const errorMessage = 'Server error occurred'; + mockOnSubmit.mockRejectedValue(new Error(errorMessage)); + + render(); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + test('clears previous errors when user changes input', async () => { + const user = userEvent.setup(); + render(); + + // Trigger validation error + const cronInput = screen.getByDisplayValue('0 2 * * *'); + await user.clear(cronInput); + await user.type(cronInput, '0 2'); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + expect(screen.getByText(/exactly 5 fields/i)).toBeInTheDocument(); + + // Fix the cron and verify error clears + await user.clear(cronInput); + await user.type(cronInput, '0 2 * * *'); + + // Error should clear when form is resubmitted successfully + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + await waitFor(() => { + expect(screen.queryByText(/exactly 5 fields/i)).not.toBeInTheDocument(); + }); + }); + }); + + describe('Cancel Functionality', () => { + test('calls onCancel when cancel button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /Cancel/i })); + + expect(mockOnCancel).toHaveBeenCalled(); + }); + + test('disables cancel button while submitting', async () => { + const user = userEvent.setup(); + mockOnSubmit.mockImplementation( + () => new Promise(resolve => setTimeout(resolve, 100)) + ); + + render(); + + const cancelButton = screen.getByRole('button', { name: /Cancel/i }); + const submitButton = screen.getByRole('button', { name: /Save Schedule/i }); + + await user.click(submitButton); + + expect(cancelButton).toBeDisabled(); + }); + }); + + describe('Accessibility', () => { + test('form has proper ARIA labels and descriptions', () => { + render(); + + expect(screen.getByLabelText(/Cron Expression/i)).toHaveAttribute('aria-describedby'); + expect(screen.getByLabelText(/Timezone/i)).toHaveAttribute('aria-describedby'); + }); + + test('error alerts have proper ARIA role', async () => { + const user = userEvent.setup(); + render(); + + const cronInput = screen.getByDisplayValue('0 2 * * *'); + await user.clear(cronInput); + await user.type(cronInput, '0 2'); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + const errorAlert = screen.getByRole('alert'); + expect(errorAlert).toBeInTheDocument(); + }); + + test('success messages have proper ARIA role', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /Save Schedule/i })); + + await waitFor(() => { + const successStatus = screen.getByRole('status'); + expect(successStatus).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/pyproject.toml b/pyproject.toml index 8a0908e7..0e544414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,8 @@ dependencies = [ "python-multipart>=0.0.9", "xhtml2pdf>=0.2.17", "aiosqlite>=0.20.0", - "python-whois>=0.9.4" + "python-whois>=0.9.4", + "croniter>=2.0.0" ] [project.scripts] diff --git a/testing/test_scheduler.py b/testing/test_scheduler.py new file mode 100644 index 00000000..543c8a8a --- /dev/null +++ b/testing/test_scheduler.py @@ -0,0 +1,233 @@ +""" +Unit tests for the scheduler module. + +Tests cover: +- Cron expression parsing with timezone awareness +- Blackout window detection (including overnight windows) +- Missed-run recovery logic +- Input validation +""" + +import pytest +from datetime import datetime +from zoneinfo import ZoneInfo + +from backend.secuscan.utils.scheduler import ( + get_next_run_time, + is_in_blackout_window, + should_recover_missed_run, + validate_cron_expression, + validate_time_format, +) + + +class TestGetNextRunTime: + """Tests for get_next_run_time function.""" + + def test_basic_daily_cron(self): + """Test parsing a simple daily cron expression.""" + # "0 2 * * *" = every day at 2 AM + next_run = get_next_run_time("0 2 * * *", "UTC") + assert isinstance(next_run, datetime) + assert next_run.hour == 2 + assert next_run.minute == 0 + + def test_timezone_aware(self): + """Test that the function respects timezone.""" + tz_ny = ZoneInfo("America/New_York") + tz_tokyo = ZoneInfo("Asia/Tokyo") + + base_time = datetime(2026, 6, 3, 12, 0, 0, tzinfo=tz_ny) + + next_run_ny = get_next_run_time("0 14 * * *", "America/New_York", base_time) + assert next_run_ny.tzinfo == tz_ny + assert next_run_ny.hour == 14 + + def test_invalid_cron_raises_error(self): + """Test that invalid cron expressions raise ValueError.""" + with pytest.raises(ValueError): + get_next_run_time("invalid cron", "UTC") + + def test_invalid_timezone_raises_error(self): + """Test that invalid timezones raise ValueError.""" + with pytest.raises(ValueError): + get_next_run_time("0 2 * * *", "Invalid/Timezone") + + def test_every_six_hours(self): + """Test parsing cron for every 6 hours.""" + # "0 */6 * * *" = every 6 hours at minute 0 + base_time = datetime(2026, 6, 3, 10, 30, 0, tzinfo=ZoneInfo("UTC")) + next_run = get_next_run_time("0 */6 * * *", "UTC", base_time) + + # Next run should be at 12:00 (next 6-hour boundary) + assert next_run.hour in [0, 6, 12, 18] + assert next_run.minute == 0 + + +class TestIsInBlackoutWindow: + """Tests for is_in_blackout_window function.""" + + def test_time_in_simple_window(self): + """Test detection of time within a simple blackout window.""" + current = datetime(2026, 6, 3, 15, 30, tzinfo=ZoneInfo("UTC")) + assert is_in_blackout_window(current, "14:00", "18:00") is True + + def test_time_outside_simple_window(self): + """Test detection of time outside a simple blackout window.""" + current = datetime(2026, 6, 3, 13, 0, tzinfo=ZoneInfo("UTC")) + assert is_in_blackout_window(current, "14:00", "18:00") is False + + def test_time_in_overnight_window(self): + """Test detection of time within an overnight blackout window.""" + # Blackout from 23:00 to 06:00 + + # Test 01:00 (should be in blackout) + current = datetime(2026, 6, 3, 1, 0, tzinfo=ZoneInfo("UTC")) + assert is_in_blackout_window(current, "23:00", "06:00") is True + + # Test 23:30 (should be in blackout) + current = datetime(2026, 6, 3, 23, 30, tzinfo=ZoneInfo("UTC")) + assert is_in_blackout_window(current, "23:00", "06:00") is True + + def test_time_outside_overnight_window(self): + """Test detection of time outside an overnight blackout window.""" + # Blackout from 23:00 to 06:00 + + # Test 12:00 (should NOT be in blackout) + current = datetime(2026, 6, 3, 12, 0, tzinfo=ZoneInfo("UTC")) + assert is_in_blackout_window(current, "23:00", "06:00") is False + + def test_empty_blackout_returns_false(self): + """Test that empty blackout times return False.""" + current = datetime(2026, 6, 3, 15, 30, tzinfo=ZoneInfo("UTC")) + assert is_in_blackout_window(current, "", "") is False + assert is_in_blackout_window(current, None, None) is False + + def test_boundary_conditions(self): + """Test behavior at exact boundary times.""" + current = datetime(2026, 6, 3, 14, 0, tzinfo=ZoneInfo("UTC")) + # At start boundary, should be in blackout + assert is_in_blackout_window(current, "14:00", "18:00") is True + + # Just before end, should be in blackout + current = datetime(2026, 6, 3, 17, 59, tzinfo=ZoneInfo("UTC")) + assert is_in_blackout_window(current, "14:00", "18:00") is True + + def test_invalid_time_format(self): + """Test that invalid time formats return False.""" + current = datetime(2026, 6, 3, 15, 30, tzinfo=ZoneInfo("UTC")) + assert is_in_blackout_window(current, "invalid", "18:00") is False + + +class TestShouldRecoverMissedRun: + """Tests for should_recover_missed_run function.""" + + def test_expected_run_not_yet_arrived(self): + """Test that recovery returns False if next run hasn't arrived yet.""" + # Last run at 10:00, cron is every 6 hours + # Next expected: 16:00. Current time: 14:00. Should NOT recover. + last_run = datetime(2026, 6, 3, 10, 0, tzinfo=ZoneInfo("UTC")) + current_time = datetime(2026, 6, 3, 14, 0, tzinfo=ZoneInfo("UTC")) + + # Mock the current time by using a base time + # (In reality, should_recover_missed_run uses datetime.now internally) + # For this test, we'll use a different approach + + result = should_recover_missed_run( + last_run, + "0 */6 * * *", # Every 6 hours + "UTC" + ) + # Hard to test without mocking datetime.now, but we can verify it returns a bool + assert isinstance(result, bool) + + def test_missed_run_not_in_blackout(self): + """Test that missed runs outside blackout windows are recovered.""" + # This requires mocking datetime.now, so we'll do a basic assertion + last_run = datetime(2026, 6, 3, 10, 0, tzinfo=ZoneInfo("UTC")) + + result = should_recover_missed_run( + last_run, + "0 */6 * * *", + "UTC", + "23:00", + "06:00" + ) + assert isinstance(result, bool) + + def test_invalid_inputs_return_false(self): + """Test that invalid inputs default to False for safety.""" + last_run = datetime(2026, 6, 3, 10, 0, tzinfo=ZoneInfo("UTC")) + + # Invalid cron should return False (no exception) + result = should_recover_missed_run( + last_run, + "invalid cron", + "UTC" + ) + assert result is False + + +class TestValidateCronExpression: + """Tests for validate_cron_expression function.""" + + def test_valid_cron_expressions(self): + """Test validation of valid cron expressions.""" + valid_crons = [ + "0 2 * * *", # Daily at 2 AM + "0 */6 * * *", # Every 6 hours + "30 14 * * MON", # Mondays at 2:30 PM + "*/15 * * * *", # Every 15 minutes + "0 0 1 * *", # Monthly on the 1st + ] + + for cron in valid_crons: + assert validate_cron_expression(cron) is True, f"Cron '{cron}' should be valid" + + def test_invalid_cron_expressions(self): + """Test validation of invalid cron expressions.""" + invalid_crons = [ + "0 2", # Too few parts + "0 2 * * * *", # Too many parts + "invalid", # Complete nonsense + "", # Empty + "0 25 * * *", # Hour out of range (25) + ] + + for cron in invalid_crons: + assert validate_cron_expression(cron) is False, f"Cron '{cron}' should be invalid" + + +class TestValidateTimeFormat: + """Tests for validate_time_format function.""" + + def test_valid_time_formats(self): + """Test validation of valid time formats.""" + valid_times = [ + "00:00", # Midnight + "23:59", # Just before midnight + "12:30", # Afternoon + "06:45", # Morning + "", # Empty is valid (optional field) + ] + + for time_str in valid_times: + assert validate_time_format(time_str) is True, f"Time '{time_str}' should be valid" + + def test_invalid_time_formats(self): + """Test validation of invalid time formats.""" + invalid_times = [ + "24:00", # Hour out of range + "12:60", # Minute out of range + "13", # Missing minutes + "25:30", # Hour too high + "12:30:00", # Includes seconds + "invalid", + ] + + for time_str in invalid_times: + assert validate_time_format(time_str) is False, f"Time '{time_str}' should be invalid" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])