true, 'endpoint' => 'https://PRIVATE-ENDPOINT.invalid/cgi', 'username' => 'FAKE_USER', 'password' => 'FAKE_SECRET_SENTINEL', 'source_timezone' => 'Europe/Madrid', 'cache_directory' => $dir]; $params = ['serviceid' => 42, 'configoptions' => ['Backup diario' => 1], 'customfields' => ['backuppc_host' => 'UNTRUSTED_PARAMS.invalid']]; $GLOBALS['actor'] = new BackupTestClient(7); $GLOBALS['db'] = [ 'tblproductconfiglinks' => [(object) ['pid' => 3, 'gid' => 5]], 'tblproductconfigoptions' => [(object) ['id' => 7, 'gid' => 5, 'optionname' => 'Backup diario', 'optiontype' => 3]], 'tblhosting' => [(object) ['id' => 42, 'userid' => 7, 'packageid' => 3], (object) ['id' => 43, 'userid' => 8, 'packageid' => 3]], 'tblcustomfields' => [(object) ['id' => 6, 'type' => 'product', 'relid' => 3, 'fieldname' => 'backuppc_host', 'fieldtype' => 'text', 'adminonly' => 'on', 'showorder' => '', 'showinvoice' => '']], 'tblcustomfieldsvalues' => [(object) ['fieldid' => 6, 'relid' => 42, 'value' => $host], (object) ['fieldid' => 6, 'relid' => 43, 'value' => 'OTHER_CUSTOMER.invalid']], ]; function altered(string $html, callable $edit): string { $d = new DOMDocument(); $previous = libxml_use_internal_errors(true); $d->loadHTML($html, LIBXML_NONET); libxml_clear_errors(); libxml_use_internal_errors($previous); $edit(new DOMXPath($d), $d); return $d->saveHTML(); } function parseFixture($html) { return BackupParser::parse($html, 'fixture-target.invalid', 'Europe/Madrid'); } function tableWith(DOMXPath $xp, $header) { foreach ($xp->query('//table') as $t) if (strpos($t->textContent, $header) !== false) return $t; throw new RuntimeException('Missing test table'); } try { writeConfig($temp, ['backups' => $config]); check(Backups::entitled($params, $config, ['Backup diario']), 'observed active integer 1'); foreach ([null, false, true, 0, 2, -1, 1.0, '1', '0', 'on', 'yes', 'No', [], new stdClass(), 'unknown'] as $value) { $p = $params; $p['configoptions']['Backup diario'] = $value; check(!Backups::entitled($p, $config, ['Backup diario']), 'unknown type/value denied'); } check(!Backups::entitled([], $config, ['Backup diario']), 'missing option'); check(!Backups::entitled($params, [], ['Backup diario']), 'default closed'); check(!Backups::entitled($params, array_replace($config, ['enabled' => false]), ['Backup diario']), 'integration disabled gate'); foreach (['', '.', '..', '/etc', 'abc/def', 'a%2fb', 'a?host=b', 'a&action=x', "a\n", ' a', str_repeat('a', 65), [], null] as $value) check(!Backups::validHost($value), 'invalid host'); foreach ([$host, 'a', 'a_B-9.example', str_repeat('a', 64)] as $value) check(Backups::validHost($value), 'valid host'); // Exact names and product/group resolution: no global LIKE or first-row choice. $originalOptions = $GLOBALS['db']['tblproductconfigoptions']; $GLOBALS['db']['tblproductconfigoptions'][] = (object) ['id' => 5, 'gid' => 3, 'optionname' => 'Backup diario', 'optiontype' => 3]; check(\VPSManager\BackupOption::applicable(3) === ['Backup diario'], 'unrelated group ignored'); check(\VPSManager\BackupOption::applicable(999) === [], 'no linked group'); $GLOBALS['db']['tblproductconfigoptions'][0]->optionname = 'Backup diario VPS'; check(\VPSManager\BackupOption::applicable(3) === ['Backup diario VPS'], 'VPS exact name'); $p = $params; $p['configoptions'] = ['Backup diario VPS' => 1]; check(!Backups::entitled($p, $config, ['Backup diario VPS']), 'legacy name not confirmed and no longer applicable'); check(!Backups::entitled($params, $config, ['Backup diario VPS']), 'inapplicable key cannot grant'); $p['configoptions']['Backup diario'] = 'SYNTHETIC_INACTIVE'; check(!Backups::entitled($p, $config, ['Backup diario VPS']), 'conflicting aliases denied'); check(strpos(end($GLOBALS['safeLogs']), 'OPTION_CONFLICT') !== false, 'conflict safe admin diagnostic'); $p['configoptions']['Backup diario'] = 1; check(!Backups::entitled($p, $config, ['Backup diario', 'Backup diario VPS']), 'multiple names cannot override canonical resolution'); $p['configoptions']['Backup diario'] = 1; $p['configoptions']['Backup diario VPS'] = '1'; check(!Backups::entitled($p, $config, ['Backup diario VPS']), 'type conflict denied'); $GLOBALS['db']['tblproductconfigoptions'][] = (object) ['id' => 12, 'gid' => 5, 'optionname' => 'Backup diario VPS', 'optiontype' => 3]; rejects(fn() => \VPSManager\BackupOption::applicable(3), 'duplicate applicable exact key ambiguous'); $GLOBALS['db']['tblproductconfigoptions'] = [(object) ['id' => 7, 'gid' => 5, 'optionname' => 'Backup diario', 'optiontype' => 3]]; check(!Backups::entitled($params, $config, []), 'option id and yesno definition alone never grant'); $data = parseFixture($html); check($data['total'] === 5 && $data['full'] === 2 && $data['incremental'] === 3, 'retained totals'); foreach ($expected['copies'] as $i => $row) { $copy = $data['copies'][$i]; check($copy['duration_minutes'] === $row['duration_minutes'], 'duration fixture'); check(number_format($copy['size_gib'], 2, '.', '') === $row['logical_size_gib_display'], 'total MiB to GiB'); check($copy['incidents'] === 'Sin incidencias registradas', 'filled=no not a failure'); check((new DateTimeImmutable('@' . $copy['start']))->setTimezone(new DateTimeZone('Europe/Madrid'))->format('Y-m-d H:i') === $row['source_start_local'], 'start date'); } $reordered = altered($html, function ($xp) { foreach ($xp->query('//table') as $t) { $rows = iterator_to_array($xp->query('./tr[not(contains(@class,"header"))]', $t)); foreach (array_reverse($rows) as $r) $t->appendChild($r); } $tables = iterator_to_array($xp->query('//table')); foreach (array_reverse($tables) as $t) $t->parentNode->appendChild($t); }); check(parseFixture($reordered) === $data, 'tables and rows reordered, joins by id'); foreach ([str_replace('Host ' . $host . ' Backup Summary', 'Host wrong.invalid Backup Summary', $html), file_get_contents(__DIR__ . '/fixtures/backups/hosts-summary.example.html'), '

Login

', '

Host ' . $host . ' Backup Summary

'] as $bad) rejects(fn() => parseFixture($bad), 'identity or missing data'); $noErrors = altered($html, fn($xp) => tableWith($xp, '#Xfer errs')->parentNode->removeChild(tableWith($xp, '#Xfer errs'))); check(parseFixture($noErrors)['copies'][0]['incidents'] === 'Información no disponible', 'missing error table unknown'); $missingCell = altered($html, function ($xp) { $row = $xp->query('./tr', tableWith($xp, '#Xfer errs'))->item(1); $row->removeChild($xp->query('./td', $row)->item(3)); }); check(parseFixture($missingCell)['copies'][0]['incidents'] === 'Información no disponible', 'missing cell not zero'); $incidents = altered($html, function ($xp) { $row = $xp->query('./tr', tableWith($xp, '#Xfer errs'))->item(1); $xp->query('./td', $row)->item(4)->nodeValue = '3'; }); check(parseFixture($incidents)['copies'][0]['incidents'] === 'Con incidencias', 'any positive counter'); $noSize = altered($html, fn($xp) => tableWith($xp, 'Totals')->parentNode->removeChild(tableWith($xp, 'Totals'))); check(parseFixture($noSize)['copies'][0]['size_gib'] === null, 'missing size unknown'); $empty = altered($html, function ($xp) { $t = tableWith($xp, 'Start Date'); foreach (iterator_to_array($xp->query('./tr[position()>1]', $t)) as $r) $t->removeChild($r); }); check(parseFixture($empty)['total'] === 0, 'recognized empty summary is zero'); $more = altered($html, function ($xp) { $t = tableWith($xp, 'Start Date'); $r = $xp->query('./tr', $t)->item(1)->cloneNode(true); $xp->query('./td', $r)->item(0)->nodeValue = '999'; $t->appendChild($r); }); check(parseFixture($more)['total'] === 6, 'six retained'); check(parseFixture(str_replace('state "idle"', 'state "auto disabled"', $html))['activity'] === null, 'unknown activity not healthy'); rejects(fn() => BackupParser::parse($html, $host, ''), 'timezone required'); rejects(fn() => BackupParser::timestamp('2026-03-29 02:30', new DateTimeZone('Europe/Madrid')), 'DST nonexistent'); rejects(fn() => BackupParser::timestamp('2026-10-25 02:30', new DateTimeZone('Europe/Madrid')), 'DST ambiguous'); rejects(fn() => BackupParser::timestamp('2026-02-30 01:00', new DateTimeZone('Europe/Madrid')), 'invalid date'); check(BackupParser::timestamp('2026-03-29 03:30', new DateTimeZone('Europe/Madrid')) > 0, 'valid DST'); $GLOBALS['response'] = ['status' => 200, 'body' => $html]; check(BackupHttp::request($config, $host) === $html, 'HTTP valid'); $call = end($GLOBALS['calls']); check($call->url === $config['endpoint'] . '?host=' . $host, 'one fixed host read only query'); check($call->options[CURLOPT_FOLLOWLOCATION] === false && $call->options[CURLOPT_SSL_VERIFYPEER] === true && $call->options[CURLOPT_SSL_VERIFYHOST] === 2, 'redirect TLS policy'); check($call->options[CURLOPT_TIMEOUT] === 5 && $call->options[CURLOPT_CONNECTTIMEOUT] === 2, 'bounded time'); foreach ([301, 302, 401, 403, 404, 500] as $status) { $GLOBALS['response']['status'] = $status; rejects(fn() => BackupHttp::request($config, $host), 'HTTP error'); } foreach (['timeout', 'TLS'] as $failure) { $GLOBALS['response'] = ['timeout' => true]; rejects(fn() => BackupHttp::request($config, $host), $failure . ' curl false'); } $GLOBALS['response'] = ['status' => 200, 'body' => str_repeat('x', 2097153)]; rejects(fn() => BackupHttp::request($config, $host), 'streaming size limit'); foreach (['http://example.invalid/cgi', 'https://u:p@example.invalid/cgi', 'https://example.invalid/cgi?action=delete'] as $url) rejects(fn() => BackupHttp::request(array_replace($config, ['endpoint' => $url]), $host), 'unsafe endpoint'); // Invoke the production callback with representative WHMCS session/database boundaries. $GLOBALS['response'] = ['status' => 200, 'body' => $html]; $_GET = ['id' => 43, 'host' => 'OTHER_CUSTOMER.invalid', 'action' => 'deleteBackup']; $_POST = $_GET; check(BackupAccess::context($params)['host'] === $host, 'mapping loaded from admin DB, request ignored'); check(isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'eligible button'); $page = vpsmanager_Backups($params); check(strpos($page['vars']['copiesHtml'], '40,68 GiB') !== false, 'controller renders'); $requests = count($GLOBALS['calls']); foreach ([[], ['Backup diario' => 'UNKNOWN'], ['Backup diario' => 0], ['Backup diario' => '1'], ['Backup diario' => true]] as $option) { $p = $params; $p['configoptions'] = $option; check(strpos(vpsmanager_Backups($p)['vars']['copiesHtml'], 'temporalmente') !== false, 'revocation denies cached data'); } $p = $params; $p['serviceid'] = 43; check(strpos(vpsmanager_Backups($p)['vars']['copiesHtml'], 'temporalmente') !== false, 'changing service id denied by owner DB check'); $GLOBALS['actor'] = null; check(strpos(vpsmanager_Backups($params)['vars']['copiesHtml'], 'temporalmente') !== false, 'no session'); $GLOBALS['actor'] = new BackupTestClient(7, false); check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'delegated user without permission'); $GLOBALS['actor'] = new BackupTestClient(7, true); check(isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'delegated account context with permission'); check(count($GLOBALS['calls']) === $requests, 'denied requests never query backend'); $GLOBALS['db']['tblcustomfields'][0]->adminonly = ''; check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'non-admin field refused'); check(strpos(vpsmanager_Backups($params)['vars']['copiesHtml'], 'temporalmente') !== false, 'invalid mapping generic'); $GLOBALS['db']['tblcustomfields'][0]->adminonly = 'on'; $ctx = ['serviceid' => 99, 'host' => $host]; $time = 1790000000; $calls = 0; $ok = function () use (&$calls, $html) { $calls++; return $html; }; $fail = static function () { throw new RuntimeException('PRIVATE-ENDPOINT FAKE_SECRET_SENTINEL'); }; $v = Backups::summary($ctx, $config, $ok, $time); check($v['available'] && !$v['stale'], 'fresh'); check(Backups::summary($ctx, $config, $fail, $time + 119)['stale'] === false, 'fresh cache'); check(Backups::summary($ctx, $config, $fail, $time + 120)['stale'] === true, 'stale marked'); check(Backups::summary($ctx, $config, $fail, $time + 900)['available'], 'stale boundary'); check(!Backups::summary($ctx, $config, $fail, $time + 901)['available'], 'stale expired'); check(!Backups::summary(['serviceid' => 100, 'host' => $host], $config, $fail, $time + 1)['available'], 'service cache isolation'); check(!Backups::summary(['serviceid' => 99, 'host' => 'changed.invalid'], $config, $fail, $time + 1)['available'], 'mapping cache isolation'); check(!Backups::summary($ctx, array_replace($config, ['endpoint' => 'https://new.invalid/cgi']), $fail, $time + 1)['available'], 'destination cache isolation'); $six = Backups::summary(['serviceid' => 101, 'host' => $host], $config, fn() => $more, $time); check($six['total'] === 6 && count($six['rows']) === 5, 'visible limit independent of total'); $render = '' . $page['vars']['copiesHtml'] . ''; foreach (['BackupPC', $host, 'OTHER_CUSTOMER', 'PRIVATE-ENDPOINT', 'FAKE_SECRET_SENTINEL', 'backuppc_host', 'num=', 'XferLOG', '367', '2084881'] as $sentinel) check(stripos($render, $sentinel) === false, 'public sentinel absence'); $v['rows'][0]['type'] = ''; check(strpos(Backups::render($v), '