1<?php 2 3namespace dokuwiki\test\ChangeLog; 4 5use dokuwiki\ChangeLog\PageChangeLog; 6 7/** 8 * Tests for dokuwiki\ChangeLog\PageChangeLog. 9 */ 10class PageChangeLogTest extends \DokuWikiTest 11{ 12 /** 13 * A page deleted through DokuWiki is recorded as its own revision, newer than the 14 * last revision that still had content. getRelativeRevision() must walk back from 15 * that deletion entry to the last content revision (issue #4635). 16 */ 17 public function testRevisionBeforeNormalDeletion() 18 { 19 $page = 'changelog_deleted'; 20 saveWikiText($page, 'first content', 'create', false); 21 $this->waitForTick(true); 22 saveWikiText($page, 'second content longer', 'edit', false); 23 $this->waitForTick(true); 24 25 $editRev = (new PageChangeLog($page))->currentRevision(); 26 27 saveWikiText($page, '', 'delete', false); 28 clearstatcache(); 29 30 $changelog = new PageChangeLog($page); 31 $delRev = $changelog->currentRevision(); 32 33 $this->assertNotEquals($editRev, $delRev, 'deletion should get its own revision'); 34 $this->assertEquals( 35 DOKU_CHANGE_TYPE_DELETE, 36 $changelog->getRevisionInfo($delRev)['type'], 37 'current revision should be the deletion' 38 ); 39 $this->assertEquals( 40 $editRev, 41 $changelog->getRelativeRevision($delRev, -1), 42 'the revision before the deletion should be the last edit' 43 ); 44 } 45 46 /** 47 * An external deletion is detected and persisted on first read as its own revision 48 * with an unknown exact date, newer than the last content revision. 49 * getRelativeRevision() must walk back from it to that last content revision 50 * (issue #4635). 51 */ 52 public function testRevisionBeforeExternalDeletion() 53 { 54 $page = 'changelog_extdeleted'; 55 saveWikiText($page, 'first content', 'create', false); 56 $this->waitForTick(true); 57 saveWikiText($page, 'second content longer', 'edit', false); 58 $this->waitForTick(true); 59 60 $editRev = (new PageChangeLog($page))->currentRevision(); 61 62 // delete the page file externally, bypassing DokuWiki 63 unlink(wikiFN($page)); 64 clearstatcache(); 65 66 // first read detects and persists the external deletion 67 $changelog = new PageChangeLog($page); 68 $delRev = $changelog->currentRevision(); 69 $delInfo = $changelog->getRevisionInfo($delRev); 70 71 $this->assertNotEquals($editRev, $delRev, 'external deletion should get its own revision'); 72 $this->assertEquals(DOKU_CHANGE_TYPE_DELETE, $delInfo['type'], 'current revision should be the deletion'); 73 $this->assertFalse($delInfo['timestamp'], 'external deletion has an unknown exact date'); 74 $this->assertEquals( 75 $editRev, 76 $changelog->getRelativeRevision($delRev, -1), 77 'the revision before the external deletion should be the last edit' 78 ); 79 } 80 81 /** 82 * A current revision's file can have its modification time bumped without any content 83 * change (a backup restore, a git checkout, ...). That must not be recorded as an 84 * external edit: the content is compared against the last revision and, when identical, 85 * the file mtime is reset to the recorded revision date instead (issue #4634). 86 */ 87 public function testTouchedFileWithUnchangedContentIsNotExternalEdit() 88 { 89 $page = 'changelog_touched'; 90 saveWikiText($page, 'first content', 'create', false); 91 92 $changelog = new PageChangeLog($page); 93 $lastRev = $changelog->currentRevision(); 94 95 // bump the file mtime forward without changing the content 96 touch(wikiFN($page), $lastRev + 1000); 97 clearstatcache(); 98 99 $changelog = new PageChangeLog($page); 100 $currentRev = $changelog->currentRevision(); 101 $currentInfo = $changelog->getRevisionInfo($currentRev); 102 103 $this->assertEquals($lastRev, $currentRev, 'unchanged content must not create an external revision'); 104 $this->assertArrayNotHasKey('timestamp', $currentInfo, 'should not be a synthesized external edit'); 105 $this->assertCount(1, $changelog->getRevisions(-1, 200), 'no external edit entry should be added'); 106 107 clearstatcache(); 108 $this->assertEquals($lastRev, filemtime(wikiFN($page)), 'file mtime should be reset to the changelog date'); 109 } 110 111 /** 112 * A deleted page that is restored externally with a preserved mtime predating the 113 * deletion (as cp -p from an older backup does) has content identical to the attic 114 * copy of the delete revision — but that copy holds the pre-delete content, so the 115 * unchanged-content shortcut must not treat it as the still-deleted current revision. 116 * It has to be detected as an external recreation, or the page stays "deleted" forever. 117 */ 118 public function testExternallyRestoredDeletedPageIsNotStillDeleted() 119 { 120 $page = 'changelog_extrestored'; 121 saveWikiText($page, 'first content', 'create', false); 122 $this->waitForTick(true); 123 saveWikiText($page, 'second content longer', 'edit', false); 124 $this->waitForTick(true); 125 126 // exact bytes the page held right before deletion (== what the delete archives) 127 $content = file_get_contents(wikiFN($page)); 128 129 saveWikiText($page, '', 'delete', false); 130 clearstatcache(); 131 132 $changelog = new PageChangeLog($page); 133 $delRev = $changelog->currentRevision(); 134 $this->assertEquals( 135 DOKU_CHANGE_TYPE_DELETE, 136 $changelog->getRevisionInfo($delRev)['type'], 137 'precondition: the page is recorded as deleted' 138 ); 139 140 // restore the pre-delete content externally, with an mtime older than the deletion 141 file_put_contents(wikiFN($page), $content); 142 touch(wikiFN($page), $delRev - 100); 143 clearstatcache(); 144 145 $changelog = new PageChangeLog($page); 146 $currentRev = $changelog->currentRevision(); 147 $currentInfo = $changelog->getRevisionInfo($currentRev); 148 149 $this->assertEquals( 150 DOKU_CHANGE_TYPE_CREATE, 151 $currentInfo['type'], 152 'the restored page must be detected as an external recreation, not stay deleted' 153 ); 154 $this->assertGreaterThan( 155 $delRev, 156 $currentRev, 157 'the recreation is dated after the delete (the restored mtime is unreliable)' 158 ); 159 $this->assertFalse($currentInfo['timestamp'], 'the recreation has an unknown exact date'); 160 $this->assertEquals( 161 strlen($content), 162 $currentInfo['sizechange'], 163 'the whole restored file counts as the size change, not zero against the delete attic' 164 ); 165 166 // and it stays undeleted on the next read (the file mtime was fixed forward, not left 167 // at the delete revision where it would collapse back onto the DELETE entry) 168 clearstatcache(); 169 $changelog = new PageChangeLog($page); 170 $this->assertEquals( 171 DOKU_CHANGE_TYPE_CREATE, 172 $changelog->getCurrentRevisionInfo()['type'], 173 'the page must remain undeleted on subsequent reads' 174 ); 175 } 176 177 /** 178 * A deleted page recreated externally with an mtime after the deletion (as a git checkout, 179 * which stamps "now", does) is an external creation dated at that file mtime. 180 */ 181 public function testExternallyRecreatedDeletedPageWithNewerMtimeIsCreation() 182 { 183 $page = 'changelog_extrecreated'; 184 saveWikiText($page, 'first content', 'create', false); 185 $this->waitForTick(true); 186 187 saveWikiText($page, '', 'delete', false); 188 clearstatcache(); 189 190 $changelog = new PageChangeLog($page); 191 $delRev = $changelog->currentRevision(); 192 $this->assertEquals( 193 DOKU_CHANGE_TYPE_DELETE, 194 $changelog->getRevisionInfo($delRev)['type'], 195 'precondition: the page is recorded as deleted' 196 ); 197 198 // recreate the page externally with an mtime after the deletion 199 $extRev = $delRev + 100; 200 file_put_contents(wikiFN($page), 'recreated content'); 201 touch(wikiFN($page), $extRev); 202 clearstatcache(); 203 204 $changelog = new PageChangeLog($page); 205 $currentRev = $changelog->currentRevision(); 206 $currentInfo = $changelog->getRevisionInfo($currentRev); 207 208 $this->assertEquals(DOKU_CHANGE_TYPE_CREATE, $currentInfo['type'], 'recreation after a delete is a creation'); 209 $this->assertEquals($extRev, $currentRev, 'the newer file mtime is trusted as the creation date'); 210 $this->assertEquals($extRev, $currentInfo['timestamp'], 'the creation has a known date (the file mtime)'); 211 $this->assertEquals(strlen('recreated content'), $currentInfo['sizechange']); 212 } 213 214 /** 215 * A page file that appears with no changelog at all (dropped straight into data/pages) is an 216 * external creation dated at its file mtime. 217 */ 218 public function testExternallyCreatedFileWithoutChangelogIsCreation() 219 { 220 $page = 'changelog_extcreated'; 221 $content = 'externally created content'; 222 io_saveFile(wikiFN($page), $content); // write the page file only, no changelog 223 $extRev = time() - 100; 224 touch(wikiFN($page), $extRev); 225 clearstatcache(); 226 227 $changelog = new PageChangeLog($page); 228 $currentRev = $changelog->currentRevision(); 229 $currentInfo = $changelog->getRevisionInfo($currentRev); 230 231 $this->assertEquals($extRev, $currentRev, 'a bare file is detected as created at its mtime'); 232 $this->assertEquals(DOKU_CHANGE_TYPE_CREATE, $currentInfo['type'], 'should be an external creation'); 233 $this->assertEquals($extRev, $currentInfo['timestamp'], 'the file mtime is the creation date'); 234 $this->assertEquals(strlen($content), $currentInfo['sizechange']); 235 } 236 237 /** 238 * An external edit whose file mtime is older than the last recorded revision (an erroneous 239 * occurrence, e.g. an old backup written over a newer page) cannot trust that mtime as the 240 * date: it is recorded as an external edit just after the last revision, with an unknown date. 241 */ 242 public function testExternalEditWithOlderMtimeGetsUnknownDate() 243 { 244 $page = 'changelog_exteditold'; 245 saveWikiText($page, 'first content', 'create', false); 246 $this->waitForTick(true); 247 saveWikiText($page, 'second content longer', 'edit', false); 248 clearstatcache(); 249 250 $changelog = new PageChangeLog($page); 251 $lastRev = $changelog->currentRevision(); 252 253 // change the content externally, but with an mtime older than the last revision 254 file_put_contents(wikiFN($page), 'externally changed content'); 255 touch(wikiFN($page), $lastRev - 100); 256 clearstatcache(); 257 258 // the erroneous older-than-last mtime is reported as a warning 259 $this->expectLogMessage('current file modification time is older than last revision date'); 260 261 $changelog = new PageChangeLog($page); 262 $currentRev = $changelog->currentRevision(); 263 $currentInfo = $changelog->getRevisionInfo($currentRev); 264 265 $this->assertEquals(DOKU_CHANGE_TYPE_EDIT, $currentInfo['type'], 'a changed file over a live page is an edit'); 266 $this->assertFalse($currentInfo['timestamp'], 'an older-than-last mtime cannot be trusted as the date'); 267 $this->assertEquals($lastRev + 1, $currentRev, 'the edit is dated just after the last revision'); 268 } 269 270 /** 271 * A detected external edit whose date predates the most recent change already recorded 272 * in the global changelog must stay out of the recent-changes feed (or it would appear 273 * above newer entries with an old date), but is still recorded in the page's own 274 * changelog (issue #4634). 275 */ 276 public function testOutOfOrderExternalEditKeptOutOfGlobalChangelog() 277 { 278 global $conf; 279 $page = 'changelog_outoforder'; 280 saveWikiText($page, 'first content', 'create', false); 281 282 $changelog = new PageChangeLog($page); 283 $createRev = $changelog->currentRevision(); 284 285 // external edit with different content, dated after the create but well before the 286 // global changelog's last-modified time 287 $globalFile = $conf['changelog']; 288 $extRev = $createRev + 10; 289 file_put_contents(wikiFN($page), 'externally edited content'); 290 touch(wikiFN($page), $extRev); 291 touch($globalFile, $createRev + 100000); 292 clearstatcache(); 293 294 $changelog = new PageChangeLog($page); 295 $detectedRev = $changelog->currentRevision(); 296 $detectedInfo = $changelog->getRevisionInfo($detectedRev); 297 298 // detected and recorded in the page's own changelog: the create plus the external edit 299 $this->assertEquals($extRev, $detectedRev, 'external edit should be detected at the file mtime'); 300 $this->assertEquals(DOKU_CHANGE_TYPE_EDIT, $detectedInfo['type'], 'should be an external edit'); 301 $this->assertEquals( 302 [$extRev, $createRev], 303 $changelog->getRevisions(-1, 200), 304 'page changelog should hold the create and the external edit' 305 ); 306 307 // ...but kept out of the global recent-changes feed 308 $this->assertStringNotContainsString( 309 "$extRev\t", 310 file_get_contents($globalFile), 311 'out-of-order external edit must not be appended to the global changelog' 312 ); 313 } 314 315 /** 316 * A genuinely current external edit (dated at or after the global changelog's last 317 * recorded change) must still reach the recent-changes feed (issue #4634). 318 */ 319 public function testCurrentExternalEditReachesGlobalChangelog() 320 { 321 global $conf; 322 $page = 'changelog_freshext'; 323 saveWikiText($page, 'first content', 'create', false); 324 325 $changelog = new PageChangeLog($page); 326 $createRev = $changelog->currentRevision(); 327 328 // external edit dated after the create, so it is newer than the feed's most recent 329 // change (the create just written there) and should be appended 330 $extRev = $createRev + 100; 331 file_put_contents(wikiFN($page), 'externally edited content'); 332 touch(wikiFN($page), $extRev); 333 clearstatcache(); 334 335 $changelog = new PageChangeLog($page); 336 $changelog->currentRevision(); 337 338 $this->assertStringContainsString( 339 "$extRev\t", 340 file_get_contents($conf['changelog']), 341 'a current external edit should be appended to the global changelog' 342 ); 343 } 344} 345