#!/usr/local/cpanel/3rdparty/bin/perl

#                                      Copyright 2025 WebPros International, LLC
#                                                           All rights reserved.
# copyright@cpanel.net                                         http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited.

use cPstrict;
use Test::Cpanel::Policy;

use Test2::V0;
use Test2::Tools::Explain;
use Test2::Tools::Compare;
use Test2::Tools::Exception;
use Test2::Plugin::NoWarnings;

use Test::MockModule;
use Test::MockFile;
use Cpanel::JSON;

BEGIN {
    $INC{'Cpanel/DBI/SQLite.pm'}              = 1;
    $INC{'Cpanel/FileUtils/TouchFile.pm'}     = 1;
    $INC{'Cpanel/Mkdir.pm'}                   = 1;
    $INC{'Cpanel/UUID.pm'}                    = 1;
    $INC{'Cpanel/Validate/FilesystemPath.pm'} = 1;
    $INC{'Cpanel/Validate/UUID.pm'}           = 1;
    $INC{'DBD/SQLite.pm'}                     = 1;
    $INC{'Time/Piece.pm'}                     = 1;
    $INC{'Time/Seconds.pm'}                   = 1;
    $INC{'Cpanel/Logger.pm'}                  = 1;

    package Cpanel::DBI::SQLite;
    sub new { return bless {}, shift; }

    package Cpanel::FileUtils::TouchFile;
    sub new { return bless {}, shift; }

    package Cpanel::Mkdir;
    sub new { return bless {}, shift; }

    package Cpanel::UUID;
    our $UUID_COUNTER = 0;

    sub random_uuid {
        $UUID_COUNTER++;
        return sprintf( '00000000-0000-0000-0000-%012d', $UUID_COUNTER );
    }

    package Cpanel::Validate::FilesystemPath;
    sub is_valid { return 1; }

    package Cpanel::Validate::UUID;

    sub validate_uuid_or_die {
        my ( $uuid, $name ) = @_;
        return 1 if $uuid =~ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
        die "Invalid UUID for $name";
    }

    package DBD::SQLite;
    sub new { return bless {}, shift; }

    package Time::Piece;
    sub new { return bless {}, shift; }

    package Time::Seconds;
    sub new { return bless {}, shift; }

    package Cpanel::Logger;
    sub new   { return bless {}, shift; }
    sub info  { }
    sub warn  { }
    sub error { }

    package MockDBH;
    sub prepare          { return bless {}, 'MockSTH'; }
    sub execute          { return 1; }
    sub fetchrow_hashref { return undef; }

    package MockSTH;
    sub execute          { return 1; }
    sub fetchrow_hashref { return undef; }

    $INC{'Whostmgr/CometBackup/Constants.pm'} = 1;

    package Whostmgr::CometBackup::Constants;
    our $BACKUP_JOBS_DB = '/tmp/backup_jobs.db';
    our $RUN_LOG_DB     = '/tmp/backup_runs.db';
    sub get_text_log_file_path { return '/var/log/comet.log'; }

    $INC{'Whostmgr/CometBackup/Scheduling.pm'} = 1;

    package Whostmgr::CometBackup::Scheduling;
    sub new         { return bless {}, shift; }
    sub next_run_at { return time() + 86400; }
}

use Whostmgr::CometBackup::BackupJobs ();

my $db_file    = '/tmp/backup_jobs.db';
my @mock_files = (
    Test::MockFile->file('/usr/local/cpanel/logs/error_log'),
    Test::MockFile->file('/var/cpanel/server_locale'),
    Test::MockFile->file($db_file),
);

subtest 'BackupJobs - new instance' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    ok( defined $jobs, 'BackupJobs instance created' );
    is( ref $jobs, 'Whostmgr::CometBackup::BackupJobs', 'Correct class' );
};

subtest 'BackupJobs - create_backup_job missing name' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    like(
        dies {
            $jobs->create_backup_job(
                schedule_type  => 'scheduled',
                backup_options => {},
                all_accounts   => 1,
                destinations   => ['cloud'],
            );
        },
        qr/name parameter required/,
        'Dies when name is missing'
    );
};

subtest 'BackupJobs - create_backup_job missing schedule_type' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    like(
        dies {
            $jobs->create_backup_job(
                name           => 'Test Job',
                backup_options => {},
                all_accounts   => 1,
                destinations   => ['cloud'],
            );
        },
        qr/schedule_type parameter required/,
        'Dies when schedule_type is missing'
    );
};

subtest 'BackupJobs - create_backup_job invalid schedule_type' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database'         => sub { } );
    $mock_module->redefine( '_validate_backup_options' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    like(
        dies {
            $jobs->create_backup_job(
                name           => 'Test Job',
                schedule_type  => 'invalid',
                backup_options => {},
                all_accounts   => 1,
                destinations   => ['cloud'],
            );
        },
        qr/schedule_type must be 'scheduled' or 'manual'/,
        'Dies when schedule_type is invalid'
    );
};

subtest 'BackupJobs - create_backup_job missing destinations' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database'         => sub { } );
    $mock_module->redefine( '_validate_backup_options' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    like(
        dies {
            $jobs->create_backup_job(
                name           => 'Test Job',
                schedule_type  => 'manual',
                backup_options => {},
                all_accounts   => 1,
            );
        },
        qr/destinations parameter required/,
        'Dies when destinations is missing'
    );
};

subtest 'BackupJobs - create_backup_job destinations not array' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database'         => sub { } );
    $mock_module->redefine( '_validate_backup_options' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    like(
        dies {
            $jobs->create_backup_job(
                name           => 'Test Job',
                schedule_type  => 'manual',
                backup_options => {},
                all_accounts   => 1,
                destinations   => 'cloud',
            );
        },
        qr/destinations must be a non-empty array reference/,
        'Dies when destinations is not an array'
    );
};

subtest 'BackupJobs - create_backup_job destination is reference' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database'         => sub { } );
    $mock_module->redefine( '_validate_backup_options' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    like(
        dies {
            $jobs->create_backup_job(
                name           => 'Test Job',
                schedule_type  => 'manual',
                backup_options => {},
                all_accounts   => 1,
                destinations   => [ { id => 'cloud' } ],
            );
        },
        qr/each destination must be a string/,
        'Dies when destination is a reference'
    );
};

subtest 'BackupJobs - create_backup_job all_accounts missing' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database'         => sub { } );
    $mock_module->redefine( '_validate_backup_options' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    like(
        dies {
            $jobs->create_backup_job(
                name           => 'Test Job',
                schedule_type  => 'manual',
                backup_options => {},
                destinations   => ['cloud'],
            );
        },
        qr/all_accounts parameter required/,
        'Dies when all_accounts is missing'
    );
};

subtest 'BackupJobs - create_backup_job scheduled needs schedule_info' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database'         => sub { } );
    $mock_module->redefine( '_validate_backup_options' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    like(
        dies {
            $jobs->create_backup_job(
                name           => 'Test Job',
                schedule_type  => 'scheduled',
                backup_options => {},
                all_accounts   => 1,
                destinations   => ['cloud'],
            );
        },
        qr/schedule_info required for scheduled jobs/,
        'Dies when schedule_info missing for scheduled job'
    );
};

subtest 'BackupJobs - get_backup_jobs empty by default' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database' => sub { } );
    $mock_module->redefine(
        '_get_database_handle' => sub {
            return bless {}, 'MockDBH';
        }
    );

    my $jobs    = Whostmgr::CometBackup::BackupJobs->new();
    my @results = $jobs->get_backup_jobs();

    is( scalar @results, 0, 'Returns empty array by default' );
};

subtest 'BackupJobs - get_backup_jobs with invalid job_id' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    like(
        dies { $jobs->get_backup_jobs( ['invalid-uuid'] ); },
        qr/Invalid UUID/,
        'Dies with invalid UUID'
    );
};

subtest 'BackupJobs - get_backup_jobs with non-array job_ids' => sub {
    my $mock_module = Test::MockModule->new('Whostmgr::CometBackup::BackupJobs');
    $mock_module->redefine( '_ensure_database' => sub { } );

    my $jobs = Whostmgr::CometBackup::BackupJobs->new();

    like(
        dies { $jobs->get_backup_jobs('not-an-array'); },
        qr/Can't use string|job_ids must be an array reference/,
        'Dies when job_ids is not an array'
    );
};

# Tests for the sanitize_text_input static function
subtest 'sanitize_text_input - basic functionality' => sub {

    # Test with undefined input
    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( undef, 'test_field' ),
        '',
        'Returns empty string for undefined input'
    );

    # Test with empty string
    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( '', 'test_field' ),
        '',
        'Returns empty string for empty input'
    );

    # Test with normal text
    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( 'Hello World', 'test_field' ),
        'Hello World',
        'Returns normal text unchanged'
    );

    # Test with text containing allowed characters
    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( "Line1\tTabbed\nLine2", 'test_field' ),
        "Line1\tTabbed\nLine2",
        'Preserves tabs and newlines'
    );
};

subtest 'sanitize_text_input - null byte removal' => sub {
    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( "test\x00value", 'test_field' ),
        'testvalue',
        'Removes null bytes'
    );
};

subtest 'sanitize_text_input - control character removal' => sub {

    # Test ASCII control characters are removed (except tab, newline, CR)
    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( "test\x01\x02\x03value", 'test_field' ),
        'testvalue',
        'Removes ASCII control characters 0x01-0x08'
    );

    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( "test\x0Bvalue", 'test_field' ),
        'testvalue',
        'Removes vertical tab (0x0B)'
    );

    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( "test\x0Cvalue", 'test_field' ),
        'testvalue',
        'Removes form feed (0x0C)'
    );

    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( "test\x7Fvalue", 'test_field' ),
        'testvalue',
        'Removes DEL character (0x7F)'
    );
};

subtest 'sanitize_text_input - malicious content rejection' => sub {

    # Test script tag detection
    like(
        dies { Whostmgr::CometBackup::BackupJobs::sanitize_text_input( '<script>alert(1)</script>', 'name' ) },
        qr/name contains potentially malicious content/,
        'Dies on script tag'
    );

    like(
        dies { Whostmgr::CometBackup::BackupJobs::sanitize_text_input( '<SCRIPT src="evil.js">', 'name' ) },
        qr/name contains potentially malicious content/,
        'Dies on script tag (case insensitive)'
    );

    # Test javascript: protocol
    like(
        dies { Whostmgr::CometBackup::BackupJobs::sanitize_text_input( 'javascript:alert(1)', 'description' ) },
        qr/description contains potentially malicious content/,
        'Dies on javascript: protocol'
    );

    # Test event handlers
    like(
        dies { Whostmgr::CometBackup::BackupJobs::sanitize_text_input( '<img onerror=alert(1)>', 'field' ) },
        qr/field contains potentially malicious content/,
        'Dies on event handler (onerror)'
    );

    like(
        dies { Whostmgr::CometBackup::BackupJobs::sanitize_text_input( '<div onclick = "evil()">', 'field' ) },
        qr/field contains potentially malicious content/,
        'Dies on event handler with spaces (onclick)'
    );
};

subtest 'sanitize_text_input - length limit' => sub {

    # Test within limit
    my $valid_string = 'x' x 1000;
    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( $valid_string, 'test_field' ),
        $valid_string,
        'Accepts string at maximum length (1000 chars)'
    );

    # Test exceeding limit
    my $long_string = 'x' x 1001;
    like(
        dies { Whostmgr::CometBackup::BackupJobs::sanitize_text_input( $long_string, 'name' ) },
        qr/name exceeds maximum length of 1000 characters/,
        'Dies when string exceeds maximum length'
    );
};

subtest 'sanitize_text_input - edge cases' => sub {

    # Test safe HTML-like content that should be allowed
    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( 'My <3 Backup', 'test_field' ),
        'My <3 Backup',
        'Allows safe HTML-like content'
    );

    # Test that scriptwriter is allowed (contains "script" but not <script)
    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( 'scriptwriter backup job', 'test_field' ),
        'scriptwriter backup job',
        'Allows "script" as normal word'
    );

    # Test unicode characters pass through
    is(
        Whostmgr::CometBackup::BackupJobs::sanitize_text_input( 'Backup für Müller', 'test_field' ),
        'Backup für Müller',
        'Preserves unicode characters'
    );
};

done_testing();
