true, 'option_values_confirmed' => true, 'active_values' => ['Backup diario' => ['SYNTHETIC_ACTIVE'], 'Backup diario VPS' => ['SYNTHETIC_ACTIVE']],
'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' => 'SYNTHETIC_ACTIVE'],
'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']), 'explicit synthetic active string');
foreach ([null, false, true, 0, 1, 1.0, '1', '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, ['option_values_confirmed' => false]), ['Backup diario']), 'confirmation 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' => 'SYNTHETIC_ACTIVE'];
check(Backups::entitled($p, $config, ['Backup diario VPS']), 'VPS synthetic active');
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'] = 'SYNTHETIC_ACTIVE';
check(Backups::entitled($p, $config, ['Backup diario', 'Backup diario VPS']), 'both require explicit same active value');
$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']] 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), '