Merge pull request #14857 from colemanw/testFormat
[civicrm-core.git] / tests / phpunit / api / v3 / LoggingTest.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | CiviCRM version 5 |
5 +--------------------------------------------------------------------+
6 | Copyright CiviCRM LLC (c) 2004-2019 |
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
9 | |
10 | CiviCRM is free software; you can copy, modify, and distribute it |
11 | under the terms of the GNU Affero General Public License |
12 | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
13 | |
14 | CiviCRM is distributed in the hope that it will be useful, but |
15 | WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
17 | See the GNU Affero General Public License for more details. |
18 | |
19 | You should have received a copy of the GNU Affero General Public |
20 | License and the CiviCRM Licensing Exception along |
21 | with this program; if not, contact CiviCRM LLC |
22 | at info[AT]civicrm[DOT]org. If you have questions about the |
23 | GNU Affero General Public License or the licensing of CiviCRM, |
24 | see the CiviCRM license FAQ at http://civicrm.org/licensing |
25 +--------------------------------------------------------------------+
26 */
27
28 /**
29 * Test class for Logging API.
30 *
31 * @package CiviCRM
32 * @group headless
33 */
34 class api_v3_LoggingTest extends CiviUnitTestCase {
35
36 /**
37 * Sets up the fixture, for example, opens a network connection.
38 *
39 * This method is called before a test is executed.
40 */
41 protected function setUp() {
42 $this->ensureTempColIsCleanedUp();
43 parent::setUp();
44 }
45
46 /**
47 * Clean up log tables.
48 */
49 protected function tearDown() {
50 $this->quickCleanup(['civicrm_email', 'civicrm_address']);
51 parent::tearDown();
52 $this->callAPISuccess('Setting', 'create', ['logging' => FALSE]);
53 $schema = new CRM_Logging_Schema();
54 $schema->dropAllLogTables();
55 CRM_Core_DAO::executeQuery("DELETE FROM civicrm_setting WHERE name LIKE 'logg%'");
56 }
57
58 /**
59 * Test that logging is successfully enabled and disabled.
60 */
61 public function testEnableDisableLogging() {
62 $this->assertEquals(0, $this->callAPISuccessGetValue('Setting', ['name' => 'logging']));
63 $this->assertLoggingEnabled(FALSE);
64
65 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
66 $this->assertLoggingEnabled(TRUE);
67 $this->checkLogTableCreated();
68 $this->checkTriggersCreated(TRUE);
69 // Create a contact to make sure they aren't borked.
70 $this->individualCreate();
71 $this->assertTrue($this->callAPISuccessGetValue('Setting', ['name' => 'logging']));
72 $this->assertEquals(1, $this->callAPISuccessGetValue('Setting', ['name' => 'logging_all_tables_uniquid']));
73 $this->assertEquals(
74 date('Y-m-d'),
75 date('Y-m-d', strtotime($this->callAPISuccessGetValue('Setting', ['name' => 'logging_uniqueid_date'])))
76 );
77
78 $this->callAPISuccess('Setting', 'create', ['logging' => FALSE]);
79 $this->assertEquals(0, $this->callAPISuccessGetValue('Setting', ['name' => 'logging']));
80 $this->assertLoggingEnabled(FALSE);
81 }
82
83 /**
84 * Test that logging is successfully enabled and disabled.
85 */
86 public function testEnableDisableLoggingWithTriggerHook() {
87 $this->hookClass->setHook('civicrm_alterLogTables', [$this, 'innodbLogTableSpec']);
88 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
89 $this->checkINNODBLogTableCreated();
90 $this->checkTriggersCreated(TRUE);
91 // Create a contact to make sure they aren't borked.
92 $this->individualCreate();
93 $this->callAPISuccess('Setting', 'create', ['logging' => FALSE]);
94 }
95
96 /**
97 * Check responsible creation when old structure log table exists.
98 *
99 * When an existing table exists NEW tables will have the varchar type for log_conn_id.
100 *
101 * Existing tables will be unchanged, and the trigger will use log_conn_id
102 * rather than uniqueId to be consistent across the tables.
103 *
104 * The settings for unique id will not be set.
105 */
106 public function testEnableLoggingLegacyLogTableExists() {
107 $this->createLegacyStyleContactLogTable();
108 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
109 $this->checkTriggersCreated(FALSE);
110 $this->assertEquals(0, $this->callAPISuccessGetValue('Setting', ['name' => 'logging_all_tables_uniquid']));
111 $this->assertEmpty($this->callAPISuccessGetValue('Setting', ['name' => 'logging_uniqueid_date']));
112 }
113
114 /**
115 * Check we can update legacy log tables using the api function.
116 */
117 public function testUpdateLegacyLogTable() {
118 $this->createLegacyStyleContactLogTable();
119 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
120 $this->callAPISuccess('System', 'updatelogtables', []);
121 $this->checkLogTableCreated();
122 $this->checkTriggersCreated(TRUE);
123 $this->assertEquals(0, $this->callAPISuccessGetValue('Setting', ['name' => 'logging_all_tables_uniquid']));
124 $this->assertEquals(
125 date('Y-m-d'),
126 date('Y-m-d', strtotime($this->callAPISuccessGetValue('Setting', ['name' => 'logging_uniqueid_date'])))
127 );
128 }
129
130 /**
131 * Check if we can create missing log tables using api.
132 */
133 public function testCreateMissingLogTables() {
134 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
135 CRM_Core_DAO::executeQuery("DROP TABLE log_civicrm_contact");
136 $this->callAPISuccess('System', 'createmissinglogtables', []);
137
138 //Assert if log_civicrm_contact is created.
139 $this->checkLogTableCreated();
140 }
141
142 /**
143 * Check we can update legacy log tables using the api function.
144 */
145 public function testUpdateLogTableHookINNODB() {
146 $this->createLegacyStyleContactLogTable();
147 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
148 $this->hookClass->setHook('civicrm_alterLogTables', [$this, 'innodbLogTableSpec']);
149 $this->callAPISuccess('System', 'updatelogtables', []);
150 $this->checkINNODBLogTableCreated();
151 $this->checkTriggersCreated(TRUE);
152 // Make sure that the absence of a hook specifying INNODB does not cause revert to archive.
153 // Only a positive action, like specifying ARCHIVE in a hook should trigger a change back to archive.
154 $this->hookClass->setHook('civicrm_alterLogTables', []);
155 $schema = new CRM_Logging_Schema();
156 $spec = $schema->getLogTableSpec();
157 $this->assertEquals([], $spec['civicrm_contact']);
158 $this->callAPISuccess('System', 'updatelogtables', []);
159 $this->checkINNODBLogTableCreated();
160 // Check if API creates new indexes when they're added by hook
161 $this->hookClass->setHook('civicrm_alterLogTables', [$this, 'innodbLogTableSpecNewIndex']);
162 $this->callAPISuccess('System', 'updatelogtables', []);
163 $this->checkINNODBLogTableCreated();
164 $this->assertContains('KEY `index_log_user_id` (`log_user_id`)', $this->checkLogTableCreated());
165 }
166
167 /**
168 * Check that if a field is added then the trigger is updated on refresh.
169 */
170 public function testRebuildTriggerAfterSchemaChange() {
171 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
172 $tables = ['civicrm_acl', 'civicrm_website'];
173 foreach ($tables as $table) {
174 CRM_Core_DAO::executeQuery("ALTER TABLE $table ADD column temp_col INT(10)");
175 }
176
177 $schema = new CRM_Logging_Schema();
178 $schema->fixSchemaDifferencesForAll(TRUE);
179
180 foreach ($tables as $table) {
181 $this->assertTrue($this->checkColumnExistsInTable('log_' . $table, 'temp_col'), 'log_' . $table . ' has temp_col');
182 $dao = CRM_Core_DAO::executeQuery("SHOW TRIGGERS LIKE '{$table}'");
183 while ($dao->fetch()) {
184 $this->assertContains('temp_col', $dao->Statement);
185 }
186 }
187 CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_acl DROP column temp_col");
188 CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_website DROP column temp_col");
189 }
190
191 /**
192 * Use a hook to declare an INNODB engine for the contact log table.
193 *
194 * @param array $logTableSpec
195 */
196 public function innodbLogTableSpec(&$logTableSpec) {
197 $logTableSpec['civicrm_contact'] = [
198 'engine' => 'InnoDB',
199 'engine_config' => 'ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=4',
200 'indexes' => [
201 'index_id' => 'id',
202 'index_log_conn_id' => 'log_conn_id',
203 'index_log_date' => 'log_date',
204 ],
205 ];
206 }
207
208 /**
209 * Set log engine to InnoDB and add one index
210 *
211 * @param array $logTableSpec
212 */
213 public function innodbLogTableSpecNewIndex(&$logTableSpec) {
214 $logTableSpec['civicrm_contact'] = [
215 'engine' => 'InnoDB',
216 'engine_config' => 'ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=4',
217 'indexes' => [
218 'index_id' => 'id',
219 'index_log_conn_id' => 'log_conn_id',
220 'index_log_date' => 'log_date',
221 // new index
222 'index_log_user_id' => 'log_user_id',
223 ],
224 ];
225 }
226
227 /**
228 * Check the log tables were created and look OK.
229 */
230 protected function checkLogTableCreated() {
231 $dao = CRM_Core_DAO::executeQuery("SHOW CREATE TABLE log_civicrm_contact");
232 $dao->fetch();
233 $this->assertEquals('log_civicrm_contact', $dao->Table);
234 $tableField = 'Create_Table';
235 $this->assertContains('`log_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,', $dao->$tableField);
236 $this->assertContains('`log_conn_id` varchar(17)', $dao->$tableField);
237 return $dao->$tableField;
238 }
239
240 /**
241 * Check the log tables were created and reflect the INNODB hook.
242 */
243 protected function checkINNODBLogTableCreated() {
244 $createTableString = $this->checkLogTableCreated();
245 $this->assertContains('ENGINE=InnoDB', $createTableString);
246 $this->assertContains('ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=4', $createTableString);
247 $this->assertContains('KEY `index_id` (`id`),', $createTableString);
248 }
249
250 /**
251 * Check the triggers were created and look OK.
252 *
253 * @param bool $unique
254 * Is the site configured for unique logging connection IDs per CRM-18193?
255 */
256 protected function checkTriggersCreated($unique) {
257 $dao = CRM_Core_DAO::executeQuery("SHOW TRIGGERS LIKE 'civicrm_contact'");
258 while ($dao->fetch()) {
259 if ($dao->Timing == 'After') {
260 if ($unique) {
261 $this->assertContains('@uniqueID', $dao->Statement);
262 }
263 else {
264 $this->assertContains('CONNECTION_ID()', $dao->Statement);
265 }
266 }
267 }
268 }
269
270 /**
271 * Assert logging is enabled or disabled as per input parameter.
272 *
273 * @param bool $expected
274 * Do we expect it to be enabled.
275 */
276 protected function assertLoggingEnabled($expected) {
277 $schema = new CRM_Logging_Schema();
278 $this->assertTrue($schema->isEnabled() === $expected);
279 }
280
281 /**
282 * Create the contact log table with log_conn_id as an integer.
283 */
284 protected function createLegacyStyleContactLogTable() {
285 CRM_Core_DAO::executeQuery("
286 CREATE TABLE log_civicrm_contact
287 (log_conn_id INT NULL, log_user_id INT NULL, log_date timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP)
288 ENGINE=ARCHIVE
289 (SELECT c.*, CURRENT_TIMESTAMP as log_date, 'Initialize' as 'log_action'
290 FROM civicrm_contact c)
291 ");
292 }
293
294 /**
295 * Test changes can be reverted.
296 */
297 public function testRevert() {
298 $contactId = $this->individualCreate();
299 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
300 // Pause for one second here to ensure the timestamps between the first create action
301 // and the second differ.
302 sleep(1);
303 CRM_Core_DAO::executeQuery("SET @uniqueID = 'woot'");
304 $timeStamp = date('Y-m-d H:i:s');
305 $this->callAPISuccess('Contact', 'create', [
306 'id' => $contactId,
307 'first_name' => 'Dopey',
308 'api.email.create' => ['email' => 'dopey@mail.com'],
309 ]
310 );
311 $email = $this->callAPISuccessGetSingle('email', ['email' => 'dopey@mail.com']);
312 $this->callAPIAndDocument('Logging', 'revert', ['log_conn_id' => 'woot', 'log_date' => $timeStamp], __FILE__, 'Revert');
313 $this->assertEquals('Anthony', $this->callAPISuccessGetValue('contact', ['id' => $contactId, 'return' => 'first_name']));
314 $this->callAPISuccessGetCount('Email', ['id' => $email['id']], 0);
315 }
316
317 /**
318 * Test changes can be reverted.
319 */
320 public function testRevertNoDate() {
321 $contactId = $this->individualCreate();
322 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
323 // Pause for one second here to ensure the timestamps between the first create action
324 // and the second differ.
325 sleep(1);
326 CRM_Core_DAO::executeQuery("SET @uniqueID = 'Wot woot'");
327 $this->callAPISuccess('Contact', 'create', [
328 'id' => $contactId,
329 'first_name' => 'Dopey',
330 'api.email.create' => ['email' => 'dopey@mail.com'],
331 ]);
332 $email = $this->callAPISuccessGetSingle('email', ['email' => 'dopey@mail.com']);
333 $this->callAPISuccess('Logging', 'revert', ['log_conn_id' => 'Wot woot']);
334 $this->assertEquals('Anthony', $this->callAPISuccessGetValue('contact', ['id' => $contactId, 'return' => 'first_name']));
335 $this->callAPISuccessGetCount('Email', ['id' => $email['id']], 0);
336 }
337
338 /**
339 * Ensure that a limited list of tables can be reverted.
340 *
341 * In this case ONLY civicrm_address is reverted and we check that email, contact and contribution
342 * entities have not been.
343 *
344 * @throws \Exception
345 */
346 public function testRevertRestrictedTables() {
347
348 CRM_Core_DAO::executeQuery("SET @uniqueID = 'temp name'");
349 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
350
351 $contactId = $this->individualCreate(['address' => [['street_address' => '27 Cool way', 'location_type_id' => 1]]]);
352 $contact = $this->callAPISuccessGetSingle('contact', ['id' => $contactId]);
353 $this->assertEquals('Anthony', $contact['first_name']);
354 $this->assertEquals('anthony_anderson@civicrm.org', $contact['email']);
355 $this->assertEquals('27 Cool way', $contact['street_address']);
356
357 sleep(1);
358 CRM_Core_DAO::executeQuery("SET @uniqueID = 'bitty bot bot'");
359 $this->callAPISuccess('Contact', 'create', [
360 'id' => $contactId,
361 'first_name' => 'Dopey',
362 'address' => [['street_address' => '25 Dorky way', 'location_type_id' => 1]],
363 'email' => ['email' => ['email' => 'dopey@mail.com', 'location_type_id' => 1]],
364 'api.contribution.create' => ['financial_type_id' => 'Donation', 'receive_date' => 'now', 'total_amount' => 10],
365 ]);
366 $contact = $this->callAPISuccessGetSingle('contact', ['id' => $contactId, 'return' => ['first_name', 'email', 'modified_date', 'street_address']]);
367 $this->assertEquals('Dopey', $contact['first_name']);
368 $this->assertEquals('dopey@mail.com', $contact['email']);
369 $this->assertEquals('25 Dorky way', $contact['street_address']);
370 $modifiedDate = $contact['modified_date'];
371 // To protect against the modified date not changing due to the updates being too close together.
372 sleep(1);
373 $loggings = $this->callAPISuccess('Logging', 'get', ['log_conn_id' => 'bitty bot bot', 'tables' => ['civicrm_address']]);
374 $this->assertEquals('civicrm_address', $loggings['values'][0]['table'], CRM_Core_DAO::executeQuery('SELECT * FROM log_civicrm_address')->toArray());
375 $this->assertEquals(1, $loggings['count'], CRM_Core_DAO::executeQuery('SELECT * FROM log_civicrm_address')->toArray());
376 $this->assertEquals('27 Cool way', $loggings['values'][0]['from']);
377 $this->assertEquals('25 Dorky way', $loggings['values'][0]['to']);
378 $this->callAPISuccess('Logging', 'revert', ['log_conn_id' => 'bitty bot bot', 'tables' => ['civicrm_address']]);
379
380 $contact = $this->callAPISuccessGetSingle('contact', ['id' => $contactId, 'return' => ['first_name', 'email', 'modified_date', 'street_address']]);
381 $this->assertEquals('Dopey', $contact['first_name']);
382 $this->assertEquals('dopey@mail.com', $contact['email']);
383 $this->assertEquals('27 Cool way', $contact['street_address']);
384 $this->callAPISuccessGetCount('Contribution', ['contact_id' => $contactId], 1);
385 $this->assertTrue(strtotime($modifiedDate) < strtotime($contact['modified_date']));
386 }
387
388 /**
389 * Test changes can be reverted.
390 */
391 public function testRevertNoDateNotUnique() {
392 $contactId = $this->individualCreate();
393 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
394 CRM_Core_DAO::executeQuery("SET @uniqueID = 'Wopity woot'");
395 $this->callAPISuccess('Contact', 'create', [
396 'id' => $contactId,
397 'first_name' => 'Dopey',
398 'api.email.create' => ['email' => 'dopey@mail.com'],
399 ]);
400 $this->callAPISuccess('Setting', 'create', ['logging_all_tables_uniquid' => FALSE]);
401 $this->callAPISuccess('Setting', 'create', ['logging_uniqueid_date' => date('Y-m-d H:i:s', strtotime('+ 1 hour'))]);
402 $this->callAPIFailure(
403 'Logging',
404 'revert',
405 ['log_conn_id' => 'Wopity woot'],
406 'The connection date must be passed in to disambiguate this logging entry per CRM-18193'
407 );
408 }
409
410 /**
411 * Test changes can be retrieved.
412 */
413 public function testGet() {
414 $contactId = $this->individualCreate();
415 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
416 CRM_Core_DAO::executeQuery("SET @uniqueID = 'wooty woot'");
417 // Add delay so the update is actually enough after the create that the timestamps differ
418 sleep(1);
419 $timeStamp = date('Y-m-d H:i:s');
420 $this->callAPISuccess('Contact', 'create', [
421 'id' => $contactId,
422 'first_name' => 'Dopey',
423 'last_name' => 'Dwarf',
424 'api.email.create' => ['email' => 'dopey@mail.com'],
425 ]);
426 $this->callAPISuccessGetSingle('email', ['email' => 'dopey@mail.com']);
427 $diffs = $this->callAPISuccess('Logging', 'get', ['log_conn_id' => 'wooty woot', 'log_date' => $timeStamp], __FUNCTION__, __FILE__);
428 $this->assertLoggingIncludes($diffs['values'], ['to' => 'Dwarf, Dopey']);
429 $this->assertLoggingIncludes($diffs['values'], ['to' => 'Mr. Dopey Dwarf II', 'table' => 'civicrm_contact', 'action' => 'Update', 'field' => 'display_name']);
430 $this->assertLoggingIncludes($diffs['values'], ['to' => 'dopey@mail.com', 'table' => 'civicrm_email', 'action' => 'Insert', 'field' => 'email']);
431 }
432
433 /**
434 * Test changes can be retrieved without log_date being required.
435 */
436 public function testGetNoDate() {
437 $contactId = $this->individualCreate();
438 $this->callAPISuccess('Setting', 'create', ['logging' => TRUE]);
439 CRM_Core_DAO::executeQuery("SET @uniqueID = 'wooty wop wop'");
440 // Perhaps if initialize & create are exactly the same time it can't cope.
441 // 1 second delay
442 sleep(1);
443 $this->callAPISuccess('Contact', 'create', [
444 'id' => $contactId,
445 'first_name' => 'Dopey',
446 'last_name' => 'Dwarf',
447 'api.email.create' => ['email' => 'dopey@mail.com'],
448 ]);
449 $this->callAPISuccessGetSingle('email', ['email' => 'dopey@mail.com']);
450 $diffs = $this->callAPIAndDocument('Logging', 'get', ['log_conn_id' => 'wooty wop wop'], __FUNCTION__, __FILE__);
451 $this->assertLoggingIncludes($diffs['values'], ['to' => 'Dwarf, Dopey']);
452 $this->assertLoggingIncludes($diffs['values'], ['to' => 'Mr. Dopey Dwarf II', 'table' => 'civicrm_contact', 'action' => 'Update', 'field' => 'display_name']);
453 $this->assertLoggingIncludes($diffs['values'], ['to' => 'dopey@mail.com', 'table' => 'civicrm_email', 'action' => 'Insert', 'field' => 'email']);
454 }
455
456 /**
457 * Assert the values in the $expect array in included in the logging diff.
458 *
459 * @param array $diffs
460 * @param array $expect
461 *
462 * @return bool
463 * @throws \CRM_Core_Exception
464 */
465 public function assertLoggingIncludes($diffs, $expect) {
466 foreach ($diffs as $diff) {
467 foreach ($expect as $expectKey => $expectValue) {
468 if ($diff[$expectKey] != $expectValue) {
469 continue;
470 }
471 return TRUE;
472 }
473 }
474 throw new CRM_Core_Exception("No match found for key : $expectKey with value : $expectValue" . print_r($diffs, 1));
475 }
476
477 /**
478 * Check if the column exists in the table.
479 *
480 * @param string $table
481 * @param string $column
482 *
483 * @return bool
484 */
485 protected function checkColumnExistsInTable($table, $column) {
486 $dao = CRM_Core_DAO::executeQuery("SHOW columns FROM {$table} WHERE Field = '{$column}'");
487 $dao->fetch(TRUE);
488 return ($dao->N == 1);
489 }
490
491 /**
492 * Helper for when it crashes and clean up needs to be done.
493 */
494 protected function ensureTempColIsCleanedUp() {
495 if ($this->checkColumnExistsInTable('civicrm_acl', 'temp_col')) {
496 CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_acl DROP Column temp_col");
497 CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_website DROP Column temp_col");
498 }
499 }
500
501 }