configuration-test-generator 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. #!/usr/bin/env php
  2. <?php
  3. /**
  4. * generates a config unit test class
  5. *
  6. * This generator is meant to test all possible configuration combinations
  7. * without having to write endless amounts of code manually.
  8. *
  9. * DANGER: Too many options/settings and too high max iteration setting may trigger
  10. * a fork bomb. Please save your work before executing this script.
  11. */
  12. define('PATH', dirname(__FILE__) . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR);
  13. include PATH . 'tst' . DIRECTORY_SEPARATOR . 'Bootstrap.php';
  14. $vd = array('view', 'delete');
  15. $vcd = array('view', 'create', 'delete');
  16. new ConfigurationTestGenerator(array(
  17. 'main/discussion' => array(
  18. array(
  19. 'setting' => true,
  20. 'tests' => array(
  21. array(
  22. 'conditions' => array('steps' => $vd),
  23. 'type' => 'MatchesRegularExpression',
  24. 'args' => array(
  25. '#<div[^>]*id="opendiscussionoption"[^>]*>#',
  26. '$content',
  27. 'outputs enabled discussion correctly',
  28. ),
  29. ), array(
  30. 'conditions' => array('steps' => array('create'), 'traffic/limit' => 10),
  31. 'settings' => array('$_POST["opendiscussion"] = "neither 1 nor 0"'),
  32. 'type' => 'Equals',
  33. 'args' => array(
  34. 1,
  35. '$response["status"]',
  36. 'when discussions are enabled, but invalid flag posted, fail to create paste',
  37. ),
  38. ), array(
  39. 'conditions' => array('steps' => array('create'), 'traffic/limit' => 10),
  40. 'settings' => array('$_POST["opendiscussion"] = "neither 1 nor 0"'),
  41. 'type' => 'False',
  42. 'args' => array(
  43. '$this->_model->exists(Helper::getPasteId())',
  44. 'when discussions are enabled, but invalid flag posted, paste is not created',
  45. ),
  46. ),
  47. ),
  48. 'affects' => $vcd,
  49. ), array(
  50. 'setting' => false,
  51. 'tests' => array(
  52. array(
  53. 'type' => 'DoesNotMatchRegularExpression',
  54. 'args' => array(
  55. '#<div[^>]*id="opendiscussionoption"[^>]*>#',
  56. '$content',
  57. 'outputs disabled discussion correctly',
  58. ),
  59. ),
  60. ),
  61. 'affects' => $vd,
  62. ),
  63. ),
  64. 'main/opendiscussion' => array(
  65. array(
  66. 'setting' => true,
  67. 'tests' => array(
  68. array(
  69. 'conditions' => array('main/discussion' => true),
  70. 'type' => 'MatchesRegularExpression',
  71. 'args' => array(
  72. '#<input[^>]+id="opendiscussion"[^>]*checked="checked"[^>]*>#',
  73. '$content',
  74. 'outputs checked discussion correctly',
  75. ),
  76. ),
  77. ),
  78. 'affects' => $vd,
  79. ), array(
  80. 'setting' => false,
  81. 'tests' => array(
  82. array(
  83. 'conditions' => array('main/discussion' => true),
  84. 'type' => 'DoesNotMatchRegularExpression',
  85. 'args' => array(
  86. '#<input[^>]+id="opendiscussion"[^>]*checked="checked"[^>]*>#',
  87. '$content',
  88. 'outputs unchecked discussion correctly',
  89. ),
  90. ),
  91. ),
  92. 'affects' => $vd,
  93. ),
  94. ),
  95. 'main/burnafterreadingselected' => array(
  96. array(
  97. 'setting' => true,
  98. 'tests' => array(
  99. array(
  100. 'type' => 'MatchesRegularExpression',
  101. 'args' => array(
  102. '#<input[^>]+id="burnafterreading"[^>]*checked="checked"[^>]*>#',
  103. '$content',
  104. 'preselects burn after reading option',
  105. ),
  106. ),
  107. ),
  108. 'affects' => array('view'),
  109. ), array(
  110. 'setting' => false,
  111. 'tests' => array(
  112. array(
  113. 'type' => 'DoesNotMatchRegularExpression',
  114. 'args' => array(
  115. '#<input[^>]+id="burnafterreading"[^>]*checked="checked"[^>]*>#',
  116. '$content',
  117. 'burn after reading option is unchecked',
  118. ),
  119. ),
  120. ),
  121. 'affects' => array('view'),
  122. ),
  123. ),
  124. 'main/password' => array(
  125. array(
  126. 'setting' => true,
  127. 'tests' => array(
  128. array(
  129. 'type' => 'MatchesRegularExpression',
  130. 'args' => array(
  131. '#<div[^>]*id="password"[^>]*>#',
  132. '$content',
  133. 'outputs password input correctly',
  134. ),
  135. ),
  136. ),
  137. 'affects' => $vd,
  138. ), array(
  139. 'setting' => false,
  140. 'tests' => array(
  141. array(
  142. 'conditions' => array('main/discussion' => true),
  143. 'type' => 'DoesNotMatchRegularExpression',
  144. 'args' => array(
  145. '#<div[^>]*id="password"[^>]*>#',
  146. '$content',
  147. 'removes password input correctly',
  148. ),
  149. ),
  150. ),
  151. 'affects' => $vd,
  152. ),
  153. ),
  154. 'main/template' => array(
  155. array(
  156. 'setting' => 'page',
  157. 'tests' => array(
  158. array(
  159. 'type' => 'MatchesRegularExpression',
  160. 'args' => array(
  161. '#<link[^>]+type="text/css"[^>]+rel="stylesheet"[^>]+href="css/privatebin\.css\\?\d[\d\.]+\d+"[^>]*/>#',
  162. '$content',
  163. 'outputs "page" stylesheet correctly',
  164. ),
  165. ), array(
  166. 'type' => 'DoesNotMatchRegularExpression',
  167. 'args' => array(
  168. '#<link[^>]+type="text/css"[^>]+rel="stylesheet"[^>]+href="css/bootstrap/bootstrap-\d[\d\.]+\d\.css"[^>]*/>#',
  169. '$content',
  170. 'removes "bootstrap" stylesheet correctly',
  171. ),
  172. ),
  173. ),
  174. 'affects' => $vd,
  175. ), array(
  176. 'setting' => 'bootstrap',
  177. 'tests' => array(
  178. array(
  179. 'type' => 'DoesNotMatchRegularExpression',
  180. 'args' => array(
  181. '#<link[^>]+type="text/css"[^>]+rel="stylesheet"[^>]+href="css/privatebin\.css\\?\d[\d\.]+\d+"[^>]*/>#',
  182. '$content',
  183. 'removes "page" stylesheet correctly',
  184. ),
  185. ), array(
  186. 'type' => 'MatchesRegularExpression',
  187. 'args' => array(
  188. '#<link[^>]+type="text/css"[^>]+rel="stylesheet"[^>]+href="css/bootstrap/bootstrap-\d[\d\.]+\d\.css"[^>]*/>#',
  189. '$content',
  190. 'outputs "bootstrap" stylesheet correctly',
  191. ),
  192. ),
  193. ),
  194. 'affects' => $vd,
  195. ),
  196. ),
  197. 'main/sizelimit' => array(
  198. array(
  199. 'setting' => 10,
  200. 'tests' => array(
  201. array(
  202. 'conditions' => array('steps' => array('create'), 'traffic/limit' => 10),
  203. 'type' => 'Equals',
  204. 'args' => array(
  205. 1,
  206. '$response["status"]',
  207. 'when sizelimit limit exceeded, fail to create paste',
  208. ),
  209. ),
  210. ),
  211. 'affects' => array('create'),
  212. ), array(
  213. 'setting' => 2097152,
  214. 'tests' => array(
  215. array(
  216. 'conditions' => array('steps' => array('create'), 'traffic/limit' => 0, 'main/burnafterreadingselected' => true),
  217. 'settings' => array('sleep(3)'),
  218. 'type' => 'Equals',
  219. 'args' => array(
  220. 0,
  221. '$response["status"]',
  222. 'when sizelimit limit is not reached, successfully create paste',
  223. ),
  224. ), array(
  225. 'conditions' => array('steps' => array('create'), 'traffic/limit' => 0, 'main/burnafterreadingselected' => true),
  226. 'settings' => array('sleep(3)'),
  227. 'type' => 'True',
  228. 'args' => array(
  229. '$this->_model->exists($response["id"])',
  230. 'when sizelimit limit is not reached, paste exists after posting data',
  231. ),
  232. ),
  233. ),
  234. 'affects' => array('create'),
  235. ),
  236. ),
  237. 'traffic/limit' => array(
  238. array(
  239. 'setting' => 0,
  240. 'tests' => array(
  241. array(
  242. 'conditions' => array('steps' => array('create'), 'main/sizelimit' => 2097152),
  243. 'type' => 'Equals',
  244. 'args' => array(
  245. 0,
  246. '$response["status"]',
  247. 'when traffic limit is disabled, successfully create paste',
  248. ),
  249. ), array(
  250. 'conditions' => array('steps' => array('create'), 'main/sizelimit' => 2097152),
  251. 'type' => 'True',
  252. 'args' => array(
  253. '$this->_model->exists($response["id"])',
  254. 'when traffic limit is disabled, paste exists after posting data',
  255. ),
  256. ),
  257. ),
  258. 'affects' => array('create'),
  259. ), array(
  260. 'setting' => 10,
  261. 'tests' => array(
  262. array(
  263. 'conditions' => array('steps' => array('create')),
  264. 'type' => 'Equals',
  265. 'args' => array(
  266. 1,
  267. '$response["status"]',
  268. 'when traffic limit is on and we do not wait, fail to create paste',
  269. ),
  270. ),
  271. ),
  272. 'affects' => array('create'),
  273. ), array(
  274. 'setting' => 2,
  275. 'tests' => array(
  276. array(
  277. 'conditions' => array('steps' => array('create'), 'main/sizelimit' => 2097152),
  278. 'settings' => array('sleep(3)'),
  279. 'type' => 'Equals',
  280. 'args' => array(
  281. 0,
  282. '$response["status"]',
  283. 'when traffic limit is on and we wait, successfully create paste',
  284. ),
  285. ), array(
  286. 'conditions' => array('steps' => array('create'), 'main/sizelimit' => 2097152),
  287. 'settings' => array('sleep(3)'),
  288. 'type' => 'True',
  289. 'args' => array(
  290. '$this->_model->exists($response["id"])',
  291. 'when traffic limit is on and we wait, paste exists after posting data',
  292. ),
  293. ),
  294. ),
  295. 'affects' => array('create'),
  296. ),
  297. ),
  298. ));
  299. class ConfigurationTestGenerator
  300. {
  301. /**
  302. * endless loop protection, since we're working with a recursive function,
  303. * creating factorial configurations
  304. * @var int
  305. */
  306. const MAX_ITERATIONS = 2000;
  307. /**
  308. * options to test
  309. * @var array
  310. */
  311. private $_options;
  312. /**
  313. * iteration count to guarantee timely end
  314. * @var int
  315. */
  316. private $_iterationCount = 0;
  317. /**
  318. * generated configurations
  319. * @var array
  320. */
  321. private $_configurations = array(
  322. array('options' => array(), 'tests' => array(), 'affects' => array()),
  323. );
  324. /**
  325. * constructor, generates the configuration test
  326. * @param array $options
  327. */
  328. public function __construct($options)
  329. {
  330. $this->_options = $options;
  331. // generate all possible combinations of options: options^settings
  332. $this->_generateConfigurations();
  333. $this->_writeConfigurationTest();
  334. }
  335. /**
  336. * write configuration test file based on generated configuration array
  337. */
  338. private function _writeConfigurationTest()
  339. {
  340. $defaultOptions = parse_ini_file(CONF_SAMPLE, true);
  341. $code = $this->_getHeader();
  342. foreach ($this->_configurations as $key => $conf) {
  343. $fullOptions = array_replace_recursive($defaultOptions, $conf['options']);
  344. $options = Helper::varExportMin($fullOptions, true);
  345. foreach ($conf['affects'] as $step) {
  346. $testCode = $preCode = array();
  347. foreach ($conf['tests'] as $tests) {
  348. foreach ($tests[0] as $test) {
  349. // skip if test does not affect this step
  350. if (!in_array($step, $tests[1])) {
  351. continue;
  352. }
  353. // skip if not all test conditions are met
  354. if (array_key_exists('conditions', $test)) {
  355. foreach ($test['conditions'] as $path => $setting) {
  356. if ($path == 'steps' && !in_array($step, $setting)) {
  357. continue 2;
  358. } elseif ($path != 'steps') {
  359. list($section, $option) = explode('/', $path);
  360. if ($fullOptions[$section][$option] !== $setting) {
  361. continue 2;
  362. }
  363. }
  364. }
  365. }
  366. if (array_key_exists('settings', $test)) {
  367. foreach ($test['settings'] as $setting) {
  368. $preCode[$setting] = $setting;
  369. }
  370. }
  371. $args = array();
  372. foreach ($test['args'] as $arg) {
  373. if (is_string($arg) && strpos($arg, '$') === 0) {
  374. $args[] = $arg;
  375. } else {
  376. $args[] = Helper::varExportMin($arg, true);
  377. }
  378. }
  379. $testCode[] = array($test['type'], implode(', ', $args));
  380. }
  381. }
  382. $code .= $this->_getFunction(
  383. ucfirst($step), $key, $options, $preCode, $testCode, $fullOptions['main']['discussion']
  384. );
  385. }
  386. }
  387. $code .= '}' . PHP_EOL;
  388. file_put_contents(dirname(__FILE__) . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'tst' . DIRECTORY_SEPARATOR . 'ConfigurationCombinationsTest.php', $code);
  389. }
  390. /**
  391. * get header of configuration test file
  392. *
  393. * @return string
  394. */
  395. private function _getHeader()
  396. {
  397. return <<<'EOT'
  398. <?php
  399. /**
  400. * DO NOT EDIT: This file is generated automatically using configGenerator.php
  401. */
  402. use PHPUnit\Framework\TestCase;
  403. use PrivateBin\Controller;
  404. use PrivateBin\Data\Filesystem;
  405. use PrivateBin\Persistence\ServerSalt;
  406. use PrivateBin\Persistence\TrafficLimiter;
  407. use PrivateBin\Request;
  408. class ConfigurationCombinationsTest extends TestCase
  409. {
  410. private $_conf;
  411. private $_model;
  412. private $_path;
  413. public function setUp(): void
  414. {
  415. /* Setup Routine */
  416. Helper::confBackup();
  417. $this->_path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'privatebin_data';
  418. $this->_model = new Filesystem(array('dir' => $this->_path));
  419. ServerSalt::setStore($this->_model);
  420. TrafficLimiter::setStore($this->_model);
  421. $this->reset();
  422. }
  423. public function tearDown(): void
  424. {
  425. /* Tear Down Routine */
  426. unlink(CONF);
  427. Helper::confRestore();
  428. Helper::rmDir($this->_path);
  429. }
  430. public function reset($configuration = array())
  431. {
  432. $_POST = array();
  433. $_GET = array();
  434. $_SERVER = array();
  435. if ($this->_model->exists(Helper::getPasteId()))
  436. $this->_model->delete(Helper::getPasteId());
  437. $configuration['model_options']['dir'] = $this->_path;
  438. Helper::createIniFile(CONF, $configuration);
  439. }
  440. EOT;
  441. }
  442. /**
  443. * get unit tests function block
  444. *
  445. * @param string $step
  446. * @param int $key
  447. * @param array $options
  448. * @param array $preCode
  449. * @param array $testCode
  450. * @return string
  451. */
  452. private function _getFunction($step, $key, &$options, $preCode, $testCode, $discussionEnabled)
  453. {
  454. if (count($testCode) == 0) {
  455. echo "skipping creation of test$step$key, no valid tests found for configuration: $options" . PHP_EOL;
  456. return '';
  457. }
  458. $preString = $testString = '';
  459. foreach ($preCode as $setting) {
  460. $preString .= " $setting;" . PHP_EOL;
  461. }
  462. foreach ($testCode as $test) {
  463. $type = $test[0];
  464. $args = $test[1];
  465. $testString .= " \$this->assert$type($args);" . PHP_EOL;
  466. }
  467. $code = <<<EOT
  468. /**
  469. * @runInSeparateProcess
  470. */
  471. public function test$step$key()
  472. {
  473. \$this->reset($options);
  474. EOT;
  475. // step specific initialization
  476. switch ($step) {
  477. case 'Create':
  478. if ($discussionEnabled) {
  479. $code .= PHP_EOL . <<<'EOT'
  480. $paste = Helper::getPasteJson();
  481. EOT;
  482. } else {
  483. $code .= PHP_EOL . <<<'EOT'
  484. $paste = json_decode(Helper::getPasteJson(), true);
  485. $paste['adata'][2] = 0;
  486. $paste = json_encode($paste);
  487. EOT;
  488. }
  489. $code .= PHP_EOL . <<<'EOT'
  490. $file = tempnam(sys_get_temp_dir(), 'FOO');
  491. file_put_contents($file, $paste);
  492. Request::setInputStream($file);
  493. $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
  494. $_SERVER['REQUEST_METHOD'] = 'POST';
  495. $_SERVER['REMOTE_ADDR'] = '::1';
  496. TrafficLimiter::canPass();
  497. EOT;
  498. break;
  499. case 'Read':
  500. $code .= PHP_EOL . <<<'EOT'
  501. $this->_model->create(Helper::getPasteId(), Helper::getPaste());
  502. $_SERVER['QUERY_STRING'] = Helper::getPasteId();
  503. $_GET[Helper::getPasteId()] = '';
  504. $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
  505. EOT;
  506. break;
  507. case 'Delete':
  508. $code .= PHP_EOL . <<<'EOT'
  509. $this->_model->create(Helper::getPasteId(), Helper::getPaste());
  510. $this->assertTrue($this->_model->exists(Helper::getPasteId()), 'paste exists before deleting data');
  511. $_GET['pasteid'] = Helper::getPasteId();
  512. $_GET['deletetoken'] = hash_hmac('sha256', Helper::getPasteId(), $this->_model->read(Helper::getPasteId())['meta']['salt']);
  513. EOT;
  514. break;
  515. }
  516. // all steps
  517. $code .= PHP_EOL . $preString;
  518. $code .= <<<'EOT'
  519. ob_start();
  520. new Controller;
  521. $content = ob_get_contents();
  522. ob_end_clean();
  523. EOT;
  524. // step specific tests
  525. switch ($step) {
  526. case 'Create':
  527. $code .= <<<'EOT'
  528. $response = json_decode($content, true);
  529. EOT;
  530. break;
  531. case 'Read':
  532. $code .= <<<'EOT'
  533. $response = json_decode($content, true);
  534. $this->assertEquals(0, $response['status'], 'outputs success status');
  535. $this->assertEquals(Helper::getPasteId(), $response['id'], 'outputs id correctly');
  536. $this->assertEquals(Helper::getPaste()['data'], $response['data'], 'outputs data correctly');
  537. EOT;
  538. break;
  539. case 'Delete':
  540. $code .= <<<'EOT'
  541. $this->assertMatchesRegularExpression(
  542. '#<div[^>]*id="status"[^>]*>.*Paste was properly deleted[^<]*</div>#s',
  543. $content,
  544. 'outputs deleted status correctly'
  545. );
  546. $this->assertFalse($this->_model->exists(Helper::getPasteId()), 'paste successfully deleted');
  547. EOT;
  548. break;
  549. }
  550. return $code . PHP_EOL . PHP_EOL . $testString . ' }' . PHP_EOL . PHP_EOL;
  551. }
  552. /**
  553. * recursive function to generate configurations based on options
  554. *
  555. * @throws Exception
  556. * @return array
  557. */
  558. private function _generateConfigurations()
  559. {
  560. // recursive factorial function
  561. if (++$this->_iterationCount > self::MAX_ITERATIONS) {
  562. echo 'max iterations reached, stopping', PHP_EOL;
  563. return $this->_configurations;
  564. }
  565. echo "generateConfigurations: iteration $this->_iterationCount", PHP_EOL;
  566. $path = key($this->_options);
  567. $settings = current($this->_options);
  568. if (next($this->_options) === false) {
  569. return $this->_configurations;
  570. }
  571. list($section, $option) = explode('/', $path);
  572. for ($c = 0, $max = count($this->_configurations); $c < $max; ++$c) {
  573. if (!array_key_exists($section, $this->_configurations[$c]['options'])) {
  574. $this->_configurations[$c]['options'][$section] = array();
  575. }
  576. if (count($settings) == 0) {
  577. throw new Exception("Check your \$options: option $option has no settings!");
  578. }
  579. // set the first setting in the original configuration
  580. $setting = current($settings);
  581. $this->_addSetting($this->_configurations[$c], $setting, $section, $option);
  582. // create clones for each of the other settings
  583. while ($setting = next($settings)) {
  584. $clone = $this->_configurations[$c];
  585. $this->_configurations[] = $this->_addSetting($clone, $setting, $section, $option);
  586. }
  587. reset($settings);
  588. }
  589. return $this->_generateConfigurations();
  590. }
  591. /**
  592. * add a setting to the given configuration
  593. *
  594. * @param array $configuration
  595. * @param array $setting
  596. * @param string $section
  597. * @param string $option
  598. * @throws Exception
  599. * @return array
  600. */
  601. private function _addSetting(&$configuration, &$setting, &$section, &$option)
  602. {
  603. if (++$this->_iterationCount > self::MAX_ITERATIONS) {
  604. echo 'max iterations reached, stopping', PHP_EOL;
  605. return $configuration;
  606. }
  607. echo "addSetting: iteration $this->_iterationCount", PHP_EOL;
  608. if (
  609. array_key_exists($option, $configuration['options'][$section]) &&
  610. $configuration['options'][$section][$option] === $setting['setting']
  611. ) {
  612. $val = Helper::varExportMin($setting['setting'], true);
  613. throw new Exception("Endless loop or error in options detected: option '$option' already exists with setting '$val' in one of the configurations!");
  614. }
  615. $configuration['options'][$section][$option] = $setting['setting'];
  616. $configuration['tests'][$option] = array($setting['tests'], $setting['affects']);
  617. foreach ($setting['affects'] as $affects) {
  618. if (!in_array($affects, $configuration['affects'])) {
  619. $configuration['affects'][] = $affects;
  620. }
  621. }
  622. return $configuration;
  623. }
  624. }