xref: /dokuwiki/_test/tests/lib/exe/ajax_requests.test.php (revision 5d8c9d422c83ef31e1acbe6e37664185196c3016)
1<?php
2
3/**
4 * @group ajax
5 */
6class ajax_requests_test extends DokuWikiTest {
7
8    /**
9     * DataProvider for the builtin Ajax calls
10     *
11     * @return array
12     */
13    public function defaultCalls() {
14        return [
15            // TODO: better logic and DOM walks
16            // Call           | POST     |   regexp pattern to match
17            [ 'linkwiz',      ['q' => ''], '/^<div class="odd type_d/' ],
18            [ 'suggestions',  ['q' => ''], null ],
19            [ 'lock',         ['id' => ''], null ],
20            [ 'draftdel',     ['id' => ''], null ],
21            [ 'medians',      ['ns' => 'some:ns'], null ],
22            [ 'medialist',    ['ns' => '', 'recent' => '', 'do' => ''], null ],
23            [ 'mediadetails', ['image' => ''], null ],
24            [ 'mediadiff',    ['image' => ''], null ],
25            [ 'mediaupload',  ['mediaid' => '', 'qqfile' => '' ], null ], // $_FILES
26            [ 'index',        ['idx' => ''], null ],
27            [ 'linkwiz',      ['q' => ''], null ],
28        ];
29    }
30
31    /**
32     * @dataProvider defaultCalls
33     * @param string $call
34     * @param array $post
35     * @param string $regexp
36     */
37    public function test_defaultCallsExist($call, $post, $regexp) {
38
39        $request = new TestRequest();
40        $response = $request->post(['call'=> $call]+$post, '/lib/exe/ajax.php');
41        $this->assertNotEquals("AJAX call '$call' unknown!\n", $response->getContent());
42
43        if (!empty($regexp)) {
44            $this->assertMatchesRegularExpression($regexp, $response->getContent());
45        }
46    }
47
48    /**
49     * callMediaupload must normalize the namespace with cleanID() before it is used.
50     *
51     * regression test for XSS reflection and passing unclened data to the ACL check
52     */
53    public function test_mediaupload_reflects_cleaned_namespace() {
54        $request = new TestRequest();
55        $response = $request->post(
56            ['call' => 'mediaupload', 'ns' => 'Foo"><script>x</script>'],
57            '/lib/exe/ajax.php'
58        );
59
60        $result = json_decode($response->getContent(), true);
61        $this->assertIsArray($result);
62        $this->assertSame(
63            'foo_script_x_script',
64            $result['ns'],
65            'the raw namespace must be cleaned before it is used'
66        );
67    }
68
69    /**
70     * Compute a security token for the given user, matching what same-process code (the
71     * request) computes for the same user and the shared test session, without leaking the
72     * temporary REMOTE_USER into the global state other tests rely on.
73     *
74     * @param string $user
75     * @return string
76     */
77    protected function validTokenFor($user) {
78        global $INPUT;
79        $oldServer = $_SERVER;
80        $oldInput = $INPUT;
81        $_SERVER['REMOTE_USER'] = $user;
82        $INPUT = new \dokuwiki\Input\Input();
83        $token = getSecurityToken();
84        $_SERVER = $oldServer;
85        $INPUT = $oldInput;
86        return $token;
87    }
88
89    /**
90     * The happy path: a logged in user with a valid token takes the page lock and the
91     * posted text is stored as a retrievable draft.
92     *
93     * Doubles as the "valid token is accepted" case: if the CSRF gate wrongly rejected a
94     * valid token the lock would stay '0' and this test would fail.
95     */
96    public function test_lock_takesLockAndSavesDraft() {
97        $id = 'lock:happy';
98        $text = 'some draft text';
99
100        $request = new TestRequest();
101        $request->setServer('REMOTE_USER', 'testuser');
102        $response = $request->post(
103            ['call' => 'lock', 'id' => $id, 'sectok' => $this->validTokenFor('testuser'), 'wikitext' => $text],
104            '/lib/exe/ajax.php'
105        );
106
107        $result = json_decode($response->getContent(), true);
108        $this->assertIsArray($result);
109        $this->assertSame([], $result['errors']);
110        $this->assertEquals('1', $result['lock'], 'the lock must be taken');
111        $this->assertNotEmpty($result['draft'], 'a draft-saved message must be returned');
112
113        // the lock is actually held on disk by the user
114        $this->assertFileExists(wikiLockFN($id));
115        $this->assertEquals('testuser', io_readFile(wikiLockFN($id)));
116
117        // the draft is retrievable and round-trips the posted text
118        $draft = new \dokuwiki\Draft($id, 'testuser');
119        $this->assertTrue($draft->isDraftAvailable());
120        $this->assertStringContainsString($text, $draft->getDraftText());
121    }
122
123    /**
124     * The lock call takes a page lock and writes a draft, so for a logged in user it
125     * must be protected against CSRF. A request carrying an invalid security token must
126     * be rejected before any lock is taken.
127     */
128    public function test_lock_rejectsInvalidSecurityToken() {
129        $id = 'lock:reject';
130
131        $request = new TestRequest();
132        $request->setServer('REMOTE_USER', 'testuser');
133        $response = $request->post(
134            ['call' => 'lock', 'id' => $id, 'sectok' => 'not-the-real-token', 'wikitext' => 'x'],
135            '/lib/exe/ajax.php'
136        );
137
138        $result = json_decode($response->getContent(), true);
139        $this->assertIsArray($result);
140        $this->assertNotEmpty($result['errors'], 'an invalid security token must be rejected');
141        $this->assertEquals('0', $result['lock'], 'no lock may be taken on a rejected request');
142        $this->assertFileDoesNotExist(wikiLockFN($id), 'no lock file may be written on a rejected request');
143    }
144
145    /**
146     * When the user lacks write permission the call refuses to lock or save a draft.
147     */
148    public function test_lock_deniedWhenNotWritable() {
149        global $conf, $AUTH_ACL;
150        $id = 'lock:denied';
151
152        $oldAcl = $AUTH_ACL;
153        $conf['useacl'] = 1;
154        $AUTH_ACL = ['*                  @ALL           0']; // deny everyone
155
156        try {
157            // anonymous: no security token needed, the write ACL is what must block this
158            $request = new TestRequest();
159            $response = $request->post(
160                ['call' => 'lock', 'id' => $id, 'wikitext' => 'x'],
161                '/lib/exe/ajax.php'
162            );
163
164            $result = json_decode($response->getContent(), true);
165            $this->assertIsArray($result);
166            $this->assertNotEmpty($result['errors'], 'a denied write must be reported');
167            $this->assertEquals('0', $result['lock']);
168            $this->assertSame('', $result['draft'], 'no draft may be saved when not writable');
169            $this->assertFileDoesNotExist(wikiLockFN($id));
170        } finally {
171            $AUTH_ACL = $oldAcl;
172        }
173    }
174
175    /**
176     * A lock already held by someone else must not be reported as freshly taken and must
177     * be left untouched.
178     */
179    public function test_lock_doesNotStealForeignLock() {
180        $id = 'lock:foreign';
181
182        // someone else already holds the lock
183        io_saveFile(wikiLockFN($id), 'someoneelse');
184
185        $request = new TestRequest();
186        $request->setServer('REMOTE_USER', 'testuser');
187        $response = $request->post(
188            ['call' => 'lock', 'id' => $id, 'sectok' => $this->validTokenFor('testuser')],
189            '/lib/exe/ajax.php'
190        );
191
192        $result = json_decode($response->getContent(), true);
193        $this->assertIsArray($result);
194        $this->assertEquals('0', $result['lock'], 'a foreign lock must not be reported as freshly taken');
195        $this->assertEquals('someoneelse', io_readFile(wikiLockFN($id)), 'the foreign lock must be left untouched');
196    }
197
198    public function test_CallNotProvided() {
199        $request = new TestRequest();
200        $response = $request->post([], '/lib/exe/ajax.php');
201        $this->assertEquals('', $response->getContent());
202    }
203
204    public function test_UnknownCall() {
205        $call = 'unknownCALL';
206        $request = new TestRequest();
207        $response = $request->post(['call'=> $call], '/lib/exe/ajax.php');
208        $this->assertEquals("AJAX call '$call' unknown!\n", $response->getContent());
209    }
210
211
212    public function test_EventOnUnknownCall() {
213        global $EVENT_HANDLER;
214        $call = 'unknownCALL';
215        $request = new TestRequest();
216
217        // referenced data from event hook
218        $hookTriggered = false;
219        $eventDataTriggered = '';
220        $dataTriggered = '';
221        $postTriggered = '';
222
223        $hookTriggered_AFTER = false;
224        $eventDataTriggered_AFTER  = '';
225        $dataTriggered_AFTER  = '';
226        $postTriggered_AFTER  = '';
227
228        $EVENT_HANDLER->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', null,
229            function($event, $data) use (&$hookTriggered, &$dataTriggered, &$eventDataTriggered, &$postTriggered) {
230                /** @var Doku_Event $event */
231                $hookTriggered = true;
232                $dataTriggered = $data;
233                $eventDataTriggered = $event->data;
234                $postTriggered = $GLOBALS['INPUT']->post->str('q');
235                $event->preventDefault();
236                $event->stopPropagation();
237                echo "captured event BEFORE\n";
238            }, 'some passed data'
239        );
240
241        $EVENT_HANDLER->register_hook('AJAX_CALL_UNKNOWN', 'AFTER', null,
242            function($event, $data) use (&$hookTriggered_AFTER , &$dataTriggered_AFTER , &$eventDataTriggered_AFTER , &$postTriggered_AFTER ) {
243                /** @var Doku_Event $event */
244                $hookTriggered_AFTER  = true;
245                $dataTriggered_AFTER  = $data;
246                $eventDataTriggered_AFTER  = $event->data;
247                $postTriggered_AFTER  = $GLOBALS['INPUT']->post->str('q');
248                $event->preventDefault();
249                $event->stopPropagation();
250                echo "captured event AFTER";
251            }, 'some passed data AFTER'
252        );
253
254
255        $response = $request->post(['call'=> $call, 'q' => 'some-post-param'], '/lib/exe/ajax.php');
256
257        // BEFORE
258        $this->assertEquals(true, $hookTriggered, 'Testing plugin did not trigger!');
259        $this->assertEquals('some passed data', $dataTriggered);
260        $this->assertEquals($call, $eventDataTriggered, 'Must pass call name as event data');
261        $this->assertEquals('some-post-param', $postTriggered);
262
263        // AFTER
264        $this->assertEquals(true, $hookTriggered_AFTER, 'Testing plugin did not trigger!');
265        $this->assertEquals('some passed data AFTER', $dataTriggered_AFTER);
266        $this->assertEquals($call, $eventDataTriggered_AFTER, 'Must pass call name as event data');
267        $this->assertEquals('some-post-param', $postTriggered_AFTER);
268
269        //output
270        $this->assertEquals("captured event BEFORE\ncaptured event AFTER", $response->getContent());
271
272    }
273}
274