Módulo VPSManager para WHMCS con configuración externa, acceso seguro al panel y métricas Graphite para LX.
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 
 
 

214 行
18 KiB

  1. <?php
  2. require __DIR__ . '/backup-whmcs-mocks.php';
  3. require __DIR__ . '/run.php'; // Existing regression + cURL mock, no real traffic.
  4. use VPSManager\Backups;
  5. use VPSManager\BackupParser;
  6. use VPSManager\BackupHttp;
  7. use VPSManager\BackupAccess;
  8. use VPSManager\BackupCache;
  9. $before = $count;
  10. $html = file_get_contents(__DIR__ . '/fixtures/backups/host-detail.example.html');
  11. $host = 'fixture-target.invalid';
  12. $expected = json_decode(file_get_contents(__DIR__ . '/fixtures/backups/expected-example.json'), true);
  13. $dir = sys_get_temp_dir() . '/copies-test-' . bin2hex(random_bytes(8));
  14. mkdir($dir, 0700);
  15. $temp = tempnam(sys_get_temp_dir(), 'copies-config-');
  16. putenv('O6H_VPSMANAGER_CONFIG=' . $temp);
  17. $config = ['enabled' => true,
  18. 'endpoint' => 'https://PRIVATE-ENDPOINT.invalid/cgi', 'username' => 'FAKE_USER', 'password' => 'FAKE_SECRET_SENTINEL',
  19. 'source_timezone' => 'Europe/Madrid', 'cache_directory' => $dir];
  20. $params = ['serviceid' => 42, 'configoptions' => ['Backup diario' => 1],
  21. 'customfields' => ['backuppc_host' => 'UNTRUSTED_PARAMS.invalid']];
  22. $GLOBALS['actor'] = new BackupTestClient(7);
  23. $GLOBALS['db'] = [
  24. 'tblproductconfiglinks' => [(object) ['pid' => 3, 'gid' => 5]],
  25. 'tblproductconfigoptions' => [(object) ['id' => 7, 'gid' => 5, 'optionname' => 'Backup diario', 'optiontype' => 3]],
  26. 'tblhosting' => [(object) ['id' => 42, 'userid' => 7, 'packageid' => 3], (object) ['id' => 43, 'userid' => 8, 'packageid' => 3]],
  27. 'tblcustomfields' => [(object) ['id' => 6, 'type' => 'product', 'relid' => 3, 'fieldname' => 'backuppc_host', 'fieldtype' => 'text', 'adminonly' => 'on', 'showorder' => '', 'showinvoice' => '']],
  28. 'tblcustomfieldsvalues' => [(object) ['fieldid' => 6, 'relid' => 42, 'value' => $host], (object) ['fieldid' => 6, 'relid' => 43, 'value' => 'OTHER_CUSTOMER.invalid']],
  29. ];
  30. function altered(string $html, callable $edit): string {
  31. $d = new DOMDocument(); $previous = libxml_use_internal_errors(true);
  32. $d->loadHTML($html, LIBXML_NONET); libxml_clear_errors(); libxml_use_internal_errors($previous);
  33. $edit(new DOMXPath($d), $d); return $d->saveHTML();
  34. }
  35. function parseFixture($html) { return BackupParser::parse($html, 'fixture-target.invalid', 'Europe/Madrid'); }
  36. function tableWith(DOMXPath $xp, $header) {
  37. foreach ($xp->query('//table') as $t) if (strpos($t->textContent, $header) !== false) return $t;
  38. throw new RuntimeException('Missing test table');
  39. }
  40. try {
  41. writeConfig($temp, ['backups' => $config]);
  42. check(Backups::entitled($params, $config, ['Backup diario']), 'observed active integer 1');
  43. foreach ([null, false, true, 0, 2, -1, 1.0, '1', '0', 'on', 'yes', 'No', [], new stdClass(), 'unknown'] as $value) {
  44. $p = $params; $p['configoptions']['Backup diario'] = $value;
  45. check(!Backups::entitled($p, $config, ['Backup diario']), 'unknown type/value denied');
  46. }
  47. check(!Backups::entitled([], $config, ['Backup diario']), 'missing option');
  48. check(!Backups::entitled($params, [], ['Backup diario']), 'default closed');
  49. check(!Backups::entitled($params, array_replace($config, ['enabled' => false]), ['Backup diario']), 'integration disabled gate');
  50. 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');
  51. foreach ([$host, 'a', 'a_B-9.example', str_repeat('a', 64)] as $value) check(Backups::validHost($value), 'valid host');
  52. // Exact names and product/group resolution: no global LIKE or first-row choice.
  53. $originalOptions = $GLOBALS['db']['tblproductconfigoptions'];
  54. $GLOBALS['db']['tblproductconfigoptions'][] = (object) ['id' => 5, 'gid' => 3, 'optionname' => 'Backup diario', 'optiontype' => 3];
  55. check(\VPSManager\BackupOption::applicable(3) === ['Backup diario'], 'unrelated group ignored');
  56. check(\VPSManager\BackupOption::applicable(999) === [], 'no linked group');
  57. $GLOBALS['db']['tblproductconfigoptions'][0]->optionname = 'Backup diario VPS';
  58. check(\VPSManager\BackupOption::applicable(3) === ['Backup diario VPS'], 'VPS exact name');
  59. $p = $params; $p['configoptions'] = ['Backup diario VPS' => 1];
  60. check(!Backups::entitled($p, $config, ['Backup diario VPS']), 'legacy name not confirmed and no longer applicable');
  61. check(!Backups::entitled($params, $config, ['Backup diario VPS']), 'inapplicable key cannot grant');
  62. $p['configoptions']['Backup diario'] = 'SYNTHETIC_INACTIVE';
  63. check(!Backups::entitled($p, $config, ['Backup diario VPS']), 'conflicting aliases denied');
  64. check(strpos(end($GLOBALS['safeLogs']), 'OPTION_CONFLICT') !== false, 'conflict safe admin diagnostic');
  65. $p['configoptions']['Backup diario'] = 1;
  66. check(!Backups::entitled($p, $config, ['Backup diario', 'Backup diario VPS']), 'multiple names cannot override canonical resolution');
  67. $p['configoptions']['Backup diario'] = 1;
  68. $p['configoptions']['Backup diario VPS'] = '1';
  69. check(!Backups::entitled($p, $config, ['Backup diario VPS']), 'type conflict denied');
  70. $GLOBALS['db']['tblproductconfigoptions'][] = (object) ['id' => 12, 'gid' => 5, 'optionname' => 'Backup diario VPS', 'optiontype' => 3];
  71. rejects(fn() => \VPSManager\BackupOption::applicable(3), 'duplicate applicable exact key ambiguous');
  72. $GLOBALS['db']['tblproductconfigoptions'] = [(object) ['id' => 7, 'gid' => 5, 'optionname' => 'Backup diario', 'optiontype' => 3]];
  73. check(!Backups::entitled($params, $config, []), 'option id and yesno definition alone never grant');
  74. $data = parseFixture($html);
  75. check($data['total'] === 5 && $data['full'] === 2 && $data['incremental'] === 3, 'retained totals');
  76. foreach ($expected['copies'] as $i => $row) {
  77. $copy = $data['copies'][$i];
  78. check($copy['duration_minutes'] === $row['duration_minutes'], 'duration fixture');
  79. check(number_format($copy['size_gib'], 2, '.', '') === $row['logical_size_gib_display'], 'total MiB to GiB');
  80. check($copy['incidents'] === 'Sin incidencias registradas', 'filled=no not a failure');
  81. check((new DateTimeImmutable('@' . $copy['start']))->setTimezone(new DateTimeZone('Europe/Madrid'))->format('Y-m-d H:i') === $row['source_start_local'], 'start date');
  82. }
  83. $reordered = altered($html, function ($xp) {
  84. foreach ($xp->query('//table') as $t) {
  85. $rows = iterator_to_array($xp->query('./tr[not(contains(@class,"header"))]', $t));
  86. foreach (array_reverse($rows) as $r) $t->appendChild($r);
  87. }
  88. $tables = iterator_to_array($xp->query('//table'));
  89. foreach (array_reverse($tables) as $t) $t->parentNode->appendChild($t);
  90. });
  91. check(parseFixture($reordered) === $data, 'tables and rows reordered, joins by id');
  92. foreach ([str_replace('Host ' . $host . ' Backup Summary', 'Host wrong.invalid Backup Summary', $html),
  93. file_get_contents(__DIR__ . '/fixtures/backups/hosts-summary.example.html'), '<h1>Login</h1><select>' . $host . '</select>',
  94. '<h1>Host ' . $host . ' Backup Summary</h1>'] as $bad) rejects(fn() => parseFixture($bad), 'identity or missing data');
  95. $noErrors = altered($html, fn($xp) => tableWith($xp, '#Xfer errs')->parentNode->removeChild(tableWith($xp, '#Xfer errs')));
  96. check(parseFixture($noErrors)['copies'][0]['incidents'] === 'Información no disponible', 'missing error table unknown');
  97. $missingCell = altered($html, function ($xp) { $row = $xp->query('./tr', tableWith($xp, '#Xfer errs'))->item(1); $row->removeChild($xp->query('./td', $row)->item(3)); });
  98. check(parseFixture($missingCell)['copies'][0]['incidents'] === 'Información no disponible', 'missing cell not zero');
  99. $incidents = altered($html, function ($xp) { $row = $xp->query('./tr', tableWith($xp, '#Xfer errs'))->item(1); $xp->query('./td', $row)->item(4)->nodeValue = '3'; });
  100. check(parseFixture($incidents)['copies'][0]['incidents'] === 'Con incidencias', 'any positive counter');
  101. $noSize = altered($html, fn($xp) => tableWith($xp, 'Totals')->parentNode->removeChild(tableWith($xp, 'Totals')));
  102. check(parseFixture($noSize)['copies'][0]['size_gib'] === null, 'missing size unknown');
  103. $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); });
  104. check(parseFixture($empty)['total'] === 0, 'recognized empty summary is zero');
  105. $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); });
  106. check(parseFixture($more)['total'] === 6, 'six retained');
  107. check(parseFixture(str_replace('state "idle"', 'state "auto disabled"', $html))['activity'] === null, 'unknown activity not healthy');
  108. rejects(fn() => BackupParser::parse($html, $host, ''), 'timezone required');
  109. rejects(fn() => BackupParser::timestamp('2026-03-29 02:30', new DateTimeZone('Europe/Madrid')), 'DST nonexistent');
  110. rejects(fn() => BackupParser::timestamp('2026-10-25 02:30', new DateTimeZone('Europe/Madrid')), 'DST ambiguous');
  111. rejects(fn() => BackupParser::timestamp('2026-02-30 01:00', new DateTimeZone('Europe/Madrid')), 'invalid date');
  112. check(BackupParser::timestamp('2026-03-29 03:30', new DateTimeZone('Europe/Madrid')) > 0, 'valid DST');
  113. $GLOBALS['response'] = ['status' => 200, 'body' => $html];
  114. check(BackupHttp::getHostSummary($config, $host) === $html, 'HTTP valid');
  115. $call = end($GLOBALS['calls']);
  116. check($call->url === $config['endpoint'] . '?host=' . $host, 'one fixed host read only query');
  117. check($call->options[CURLOPT_FOLLOWLOCATION] === false && $call->options[CURLOPT_SSL_VERIFYPEER] === true && $call->options[CURLOPT_SSL_VERIFYHOST] === 2, 'redirect TLS policy');
  118. check($call->options[CURLOPT_TIMEOUT] === 5 && $call->options[CURLOPT_CONNECTTIMEOUT] === 2, 'bounded time');
  119. foreach ([301, 302, 401, 403, 404, 500] as $status) { $GLOBALS['response']['status'] = $status; rejects(fn() => BackupHttp::getHostSummary($config, $host), 'HTTP error'); }
  120. foreach (['timeout', 'TLS'] as $failure) { $GLOBALS['response'] = ['timeout' => true]; rejects(fn() => BackupHttp::getHostSummary($config, $host), $failure . ' curl false'); }
  121. $GLOBALS['response'] = ['status' => 200, 'body' => str_repeat('x', 2097153)];
  122. rejects(fn() => BackupHttp::getHostSummary($config, $host), 'streaming size limit');
  123. foreach (['http://example.invalid/cgi', 'https://u:p@example.invalid/cgi', 'https://example.invalid/cgi?action=delete'] as $url) rejects(fn() => BackupHttp::getHostSummary(array_replace($config, ['endpoint' => $url]), $host), 'unsafe endpoint');
  124. // All upstream action injection vectors must be rejected or ignored.
  125. check(get_class_methods(BackupHttp::class) === ['getHostSummary'], 'closed summary-only transport API');
  126. $actions = ['deleteBackup', 'Start_Full_Backup', 'Start_Incr_Backup', 'Stop_Dequeue_Backup', 'keepBackup', 'browse', 'editConfig'];
  127. foreach ($actions as $action) {
  128. $requestsBefore = count($GLOBALS['calls']);
  129. foreach (['&action=', '?action=', '%26action%3D'] as $separator) {
  130. rejects(fn() => BackupHttp::getHostSummary($config, $host . $separator . $action), 'host action injection denied');
  131. }
  132. rejects(fn() => BackupHttp::getHostSummary(array_replace($config, ['endpoint' => $config['endpoint'] . '?action=' . $action]), $host), 'endpoint action injection denied');
  133. check(count($GLOBALS['calls']) === $requestsBefore, 'invalid inputs cause no upstream request');
  134. $_GET = $_POST = $_REQUEST = ['action' => $action, 'host' => 'attacker.invalid', 'endpoint' => 'https://attacker.invalid/cgi', 'num' => '1', 'type' => 'full', 'keep' => '1'];
  135. $injected = $params + $_GET;
  136. $injected['customfields']['backuppc_host'] = 'attacker.invalid';
  137. $uncached = array_replace($config, ['cache_ttl_seconds' => 0]);
  138. writeConfig($temp, ['backups' => $uncached]);
  139. $GLOBALS['response'] = ['status' => 200, 'body' => $html . '<a href="?action=' . $action . '">link</a><form action="?action=' . $action . '"></form>'];
  140. $page = vpsmanager_Backups($injected);
  141. check(count($GLOBALS['calls']) === $requestsBefore + 1, 'exactly one summary request; HTML actions never followed');
  142. $call = end($GLOBALS['calls']);
  143. check($call->url === $config['endpoint'] . '?host=' . $host, 'browser host endpoint and all action parameters ignored');
  144. check($call->options[CURLOPT_HTTPGET] === true && !isset($call->options[CURLOPT_POSTFIELDS]), 'GET only without request body');
  145. check(strpos($page['vars']['copiesHtml'], $action) === false, 'upstream action absent from public view');
  146. $denied = $injected; $denied['configoptions']['Backup diario'] = 0;
  147. check(BackupAccess::context($denied) === null, 'no mapping before entitlement');
  148. vpsmanager_Backups($denied);
  149. $denied['serviceid'] = 43;
  150. vpsmanager_Backups($denied);
  151. check(count($GLOBALS['calls']) === $requestsBefore + 1, 'uncontracted and foreign services cannot propagate actions');
  152. }
  153. $_GET = $_POST = $_REQUEST = [];
  154. writeConfig($temp, ['backups' => $config]);
  155. foreach (['FAKE_USER', 'FAKE_SECRET_SENTINEL', 'PRIVATE-ENDPOINT'] as $secret) {
  156. check(strpos(implode(' ', $GLOBALS['safeLogs']), $secret) === false, 'no credentials or endpoint in logs');
  157. }
  158. // Invoke the production callback with representative WHMCS session/database boundaries.
  159. $GLOBALS['response'] = ['status' => 200, 'body' => $html];
  160. $_GET = ['id' => 43, 'host' => 'OTHER_CUSTOMER.invalid', 'action' => 'deleteBackup']; $_POST = $_GET;
  161. check(BackupAccess::context($params)['host'] === $host, 'mapping loaded from admin DB, request ignored');
  162. check(isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'eligible button');
  163. $page = vpsmanager_Backups($params);
  164. check(strpos($page['vars']['copiesHtml'], '40,68 GiB') !== false, 'controller renders');
  165. $requests = count($GLOBALS['calls']);
  166. 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'); }
  167. $p = $params; $p['serviceid'] = 43;
  168. check(strpos(vpsmanager_Backups($p)['vars']['copiesHtml'], 'temporalmente') !== false, 'changing service id denied by owner DB check');
  169. $GLOBALS['actor'] = null;
  170. check(strpos(vpsmanager_Backups($params)['vars']['copiesHtml'], 'temporalmente') !== false, 'no session');
  171. $GLOBALS['actor'] = new BackupTestClient(7, false);
  172. check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'delegated user without permission');
  173. $GLOBALS['actor'] = new BackupTestClient(7, true);
  174. check(isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'delegated account context with permission');
  175. check(count($GLOBALS['calls']) === $requests, 'denied requests never query backend');
  176. $GLOBALS['permissionClients'] = [new BackupTestClient(8)];
  177. check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'permission on another account does not grant access');
  178. $GLOBALS['permissionClients'] = [(object)['id'=>7]];
  179. check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'unexpected permission model denied');
  180. unset($GLOBALS['permissionClients']);
  181. $GLOBALS['db']['tblcustomfields'][0]->adminonly = '';
  182. check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'non-admin field refused');
  183. check(strpos(vpsmanager_Backups($params)['vars']['copiesHtml'], 'temporalmente') !== false, 'invalid mapping generic');
  184. $GLOBALS['db']['tblcustomfields'][0]->adminonly = 'on';
  185. $ctx = ['serviceid' => 99, 'host' => $host]; $time = 1790000000; $calls = 0;
  186. $ok = function () use (&$calls, $html) { $calls++; return $html; };
  187. $fail = static function () { throw new RuntimeException('PRIVATE-ENDPOINT FAKE_SECRET_SENTINEL'); };
  188. $v = Backups::summary($ctx, $config, $ok, $time);
  189. check($v['available'] && !$v['stale'], 'fresh');
  190. check(Backups::summary($ctx, $config, $fail, $time + 119)['stale'] === false, 'fresh cache');
  191. check(Backups::summary($ctx, $config, $fail, $time + 120)['stale'] === true, 'stale marked');
  192. check(Backups::summary($ctx, $config, $fail, $time + 900)['available'], 'stale boundary');
  193. check(!Backups::summary($ctx, $config, $fail, $time + 901)['available'], 'stale expired');
  194. check(!Backups::summary(['serviceid' => 100, 'host' => $host], $config, $fail, $time + 1)['available'], 'service cache isolation');
  195. check(!Backups::summary(['serviceid' => 99, 'host' => 'changed.invalid'], $config, $fail, $time + 1)['available'], 'mapping cache isolation');
  196. check(!Backups::summary($ctx, array_replace($config, ['endpoint' => 'https://new.invalid/cgi']), $fail, $time + 1)['available'], 'destination cache isolation');
  197. $six = Backups::summary(['serviceid' => 101, 'host' => $host], $config, fn() => $more, $time);
  198. check($six['total'] === 6 && count($six['rows']) === 5, 'visible limit independent of total');
  199. $render = '<!doctype html><html><body>' . $page['vars']['copiesHtml'] . '</body></html>';
  200. 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');
  201. $v['rows'][0]['type'] = '<script>alert(1)</script>';
  202. check(strpos(Backups::render($v), '<script>') === false && strpos(Backups::render($v), '&lt;script&gt;') !== false, 'HTML escaped');
  203. foreach (glob($dir . '/*.json') as $f) {
  204. $cached = file_get_contents($f);
  205. check(strpos($cached, $host) === false && strpos($cached, 'FAKE_SECRET') === false && strpos($cached, '<html') === false, 'private normalized cache only');
  206. check((fileperms($f) & 0077) === 0, 'cache file private');
  207. }
  208. if ($path = getenv('BACKUPS_RENDER_OUTPUT')) file_put_contents($path, $render);
  209. echo 'PASS: ' . ($count - $before) . " backup assertions; observed integer entitlement values; WHMCS/HTTP mocks.\n";
  210. } finally {
  211. foreach (glob($dir . '/*') as $f) unlink($f);
  212. rmdir($dir); unlink($temp); putenv('O6H_VPSMANAGER_CONFIG');
  213. }