]>
Commit | Line | Data |
---|---|---|
18e67967 A |
1 | <?php |
2 | ||
3 | namespace Shaarli\Api; | |
4 | ||
e26e2060 | 5 | use Shaarli\Bookmark\Bookmark; |
00af48d9 | 6 | use Shaarli\Http\Base64Url; |
7a9daac5 | 7 | |
18e67967 A |
8 | /** |
9 | * Class ApiUtilsTest | |
10 | */ | |
a5a9cf23 | 11 | class ApiUtilsTest extends \Shaarli\TestCase |
18e67967 A |
12 | { |
13 | /** | |
14 | * Force the timezone for ISO datetimes. | |
15 | */ | |
8f60e120 | 16 | public static function setUpBeforeClass(): void |
18e67967 A |
17 | { |
18 | date_default_timezone_set('UTC'); | |
19 | } | |
20 | ||
21 | /** | |
22 | * Generate a valid JWT token. | |
23 | * | |
24 | * @param string $secret API secret used to generate the signature. | |
25 | * | |
26 | * @return string Generated token. | |
27 | */ | |
28 | public static function generateValidJwtToken($secret) | |
29 | { | |
7a9daac5 | 30 | $header = Base64Url::encode('{ |
18e67967 A |
31 | "typ": "JWT", |
32 | "alg": "HS512" | |
33 | }'); | |
7a9daac5 | 34 | $payload = Base64Url::encode('{ |
18e67967 A |
35 | "iat": '. time() .' |
36 | }'); | |
067c2dd8 | 37 | $signature = Base64Url::encode(hash_hmac('sha512', $header .'.'. $payload, $secret, true)); |
18e67967 A |
38 | return $header .'.'. $payload .'.'. $signature; |
39 | } | |
40 | ||
41 | /** | |
42 | * Generate a JWT token from given header and payload. | |
43 | * | |
44 | * @param string $header Header in JSON format. | |
45 | * @param string $payload Payload in JSON format. | |
46 | * @param string $secret API secret used to hash the signature. | |
47 | * | |
48 | * @return string JWT token. | |
49 | */ | |
50 | public static function generateCustomJwtToken($header, $payload, $secret) | |
51 | { | |
7a9daac5 V |
52 | $header = Base64Url::encode($header); |
53 | $payload = Base64Url::encode($payload); | |
54 | $signature = Base64Url::encode(hash_hmac('sha512', $header . '.' . $payload, $secret, true)); | |
18e67967 A |
55 | return $header . '.' . $payload . '.' . $signature; |
56 | } | |
57 | ||
58 | /** | |
59 | * Test validateJwtToken() with a valid JWT token. | |
60 | */ | |
61 | public function testValidateJwtTokenValid() | |
62 | { | |
63 | $secret = 'WarIsPeace'; | |
def39d0d | 64 | $this->assertTrue(ApiUtils::validateJwtToken(self::generateValidJwtToken($secret), $secret)); |
18e67967 A |
65 | } |
66 | ||
67 | /** | |
68 | * Test validateJwtToken() with a malformed JWT token. | |
18e67967 A |
69 | */ |
70 | public function testValidateJwtTokenMalformed() | |
71 | { | |
b1baca99 A |
72 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
73 | $this->expectExceptionMessage('Malformed JWT token'); | |
74 | ||
18e67967 A |
75 | $token = 'ABC.DEF'; |
76 | ApiUtils::validateJwtToken($token, 'foo'); | |
77 | } | |
78 | ||
79 | /** | |
80 | * Test validateJwtToken() with an empty JWT token. | |
18e67967 A |
81 | */ |
82 | public function testValidateJwtTokenMalformedEmpty() | |
83 | { | |
b1baca99 A |
84 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
85 | $this->expectExceptionMessage('Malformed JWT token'); | |
86 | ||
18e67967 A |
87 | $token = false; |
88 | ApiUtils::validateJwtToken($token, 'foo'); | |
89 | } | |
90 | ||
91 | /** | |
92 | * Test validateJwtToken() with a JWT token without header. | |
18e67967 A |
93 | */ |
94 | public function testValidateJwtTokenMalformedEmptyHeader() | |
95 | { | |
b1baca99 A |
96 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
97 | $this->expectExceptionMessage('Malformed JWT token'); | |
98 | ||
18e67967 A |
99 | $token = '.payload.signature'; |
100 | ApiUtils::validateJwtToken($token, 'foo'); | |
101 | } | |
102 | ||
103 | /** | |
104 | * Test validateJwtToken() with a JWT token without payload | |
18e67967 A |
105 | */ |
106 | public function testValidateJwtTokenMalformedEmptyPayload() | |
107 | { | |
b1baca99 A |
108 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
109 | $this->expectExceptionMessage('Malformed JWT token'); | |
110 | ||
18e67967 A |
111 | $token = 'header..signature'; |
112 | ApiUtils::validateJwtToken($token, 'foo'); | |
113 | } | |
114 | ||
115 | /** | |
116 | * Test validateJwtToken() with a JWT token with an empty signature. | |
18e67967 A |
117 | */ |
118 | public function testValidateJwtTokenInvalidSignatureEmpty() | |
119 | { | |
b1baca99 A |
120 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
121 | $this->expectExceptionMessage('Invalid JWT signature'); | |
122 | ||
18e67967 A |
123 | $token = 'header.payload.'; |
124 | ApiUtils::validateJwtToken($token, 'foo'); | |
125 | } | |
126 | ||
127 | /** | |
128 | * Test validateJwtToken() with a JWT token with an invalid signature. | |
18e67967 A |
129 | */ |
130 | public function testValidateJwtTokenInvalidSignature() | |
131 | { | |
b1baca99 A |
132 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
133 | $this->expectExceptionMessage('Invalid JWT signature'); | |
134 | ||
18e67967 A |
135 | $token = 'header.payload.nope'; |
136 | ApiUtils::validateJwtToken($token, 'foo'); | |
137 | } | |
138 | ||
139 | /** | |
140 | * Test validateJwtToken() with a JWT token with a signature generated with the wrong API secret. | |
18e67967 A |
141 | */ |
142 | public function testValidateJwtTokenInvalidSignatureSecret() | |
143 | { | |
b1baca99 A |
144 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
145 | $this->expectExceptionMessage('Invalid JWT signature'); | |
146 | ||
18e67967 A |
147 | ApiUtils::validateJwtToken(self::generateValidJwtToken('foo'), 'bar'); |
148 | } | |
149 | ||
150 | /** | |
151 | * Test validateJwtToken() with a JWT token with a an invalid header (not JSON). | |
18e67967 A |
152 | */ |
153 | public function testValidateJwtTokenInvalidHeader() | |
154 | { | |
b1baca99 A |
155 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
156 | $this->expectExceptionMessage('Invalid JWT header'); | |
157 | ||
18e67967 A |
158 | $token = $this->generateCustomJwtToken('notJSON', '{"JSON":1}', 'secret'); |
159 | ApiUtils::validateJwtToken($token, 'secret'); | |
160 | } | |
161 | ||
162 | /** | |
163 | * Test validateJwtToken() with a JWT token with a an invalid payload (not JSON). | |
18e67967 A |
164 | */ |
165 | public function testValidateJwtTokenInvalidPayload() | |
166 | { | |
b1baca99 A |
167 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
168 | $this->expectExceptionMessage('Invalid JWT payload'); | |
169 | ||
18e67967 A |
170 | $token = $this->generateCustomJwtToken('{"JSON":1}', 'notJSON', 'secret'); |
171 | ApiUtils::validateJwtToken($token, 'secret'); | |
172 | } | |
173 | ||
174 | /** | |
175 | * Test validateJwtToken() with a JWT token without issued time. | |
18e67967 A |
176 | */ |
177 | public function testValidateJwtTokenInvalidTimeEmpty() | |
178 | { | |
b1baca99 A |
179 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
180 | $this->expectExceptionMessage('Invalid JWT issued time'); | |
181 | ||
18e67967 A |
182 | $token = $this->generateCustomJwtToken('{"JSON":1}', '{"JSON":1}', 'secret'); |
183 | ApiUtils::validateJwtToken($token, 'secret'); | |
184 | } | |
185 | ||
186 | /** | |
187 | * Test validateJwtToken() with an expired JWT token. | |
18e67967 A |
188 | */ |
189 | public function testValidateJwtTokenInvalidTimeExpired() | |
190 | { | |
b1baca99 A |
191 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
192 | $this->expectExceptionMessage('Invalid JWT issued time'); | |
193 | ||
18e67967 A |
194 | $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() - 600) . '}', 'secret'); |
195 | ApiUtils::validateJwtToken($token, 'secret'); | |
196 | } | |
197 | ||
198 | /** | |
199 | * Test validateJwtToken() with a JWT token issued in the future. | |
18e67967 A |
200 | */ |
201 | public function testValidateJwtTokenInvalidTimeFuture() | |
202 | { | |
b1baca99 A |
203 | $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class); |
204 | $this->expectExceptionMessage('Invalid JWT issued time'); | |
205 | ||
18e67967 A |
206 | $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() + 60) . '}', 'secret'); |
207 | ApiUtils::validateJwtToken($token, 'secret'); | |
208 | } | |
c3b00963 A |
209 | |
210 | /** | |
211 | * Test formatLink() with a link using all useful fields. | |
212 | */ | |
213 | public function testFormatLinkComplete() | |
214 | { | |
215 | $indexUrl = 'https://domain.tld/sub/'; | |
e26e2060 | 216 | $data = [ |
c3b00963 A |
217 | 'id' => 12, |
218 | 'url' => 'http://lol.lol', | |
219 | 'shorturl' => 'abc', | |
220 | 'title' => 'Important Title', | |
221 | 'description' => 'It is very lol<tag>' . PHP_EOL . 'new line', | |
222 | 'tags' => 'blip .blop ', | |
223 | 'private' => '1', | |
224 | 'created' => \DateTime::createFromFormat('Ymd_His', '20170107_160102'), | |
225 | 'updated' => \DateTime::createFromFormat('Ymd_His', '20170107_160612'), | |
226 | ]; | |
e26e2060 A |
227 | $bookmark = new Bookmark(); |
228 | $bookmark->fromArray($data); | |
c3b00963 A |
229 | |
230 | $expected = [ | |
231 | 'id' => 12, | |
232 | 'url' => 'http://lol.lol', | |
233 | 'shorturl' => 'abc', | |
234 | 'title' => 'Important Title', | |
235 | 'description' => 'It is very lol<tag>' . PHP_EOL . 'new line', | |
236 | 'tags' => ['blip', '.blop'], | |
237 | 'private' => true, | |
238 | 'created' => '2017-01-07T16:01:02+00:00', | |
239 | 'updated' => '2017-01-07T16:06:12+00:00', | |
240 | ]; | |
241 | ||
e26e2060 | 242 | $this->assertEquals($expected, ApiUtils::formatLink($bookmark, $indexUrl)); |
c3b00963 A |
243 | } |
244 | ||
245 | /** | |
246 | * Test formatLink() with only minimal fields filled, and internal link. | |
247 | */ | |
248 | public function testFormatLinkMinimalNote() | |
249 | { | |
250 | $indexUrl = 'https://domain.tld/sub/'; | |
e26e2060 | 251 | $data = [ |
c3b00963 A |
252 | 'id' => 12, |
253 | 'url' => '?abc', | |
254 | 'shorturl' => 'abc', | |
255 | 'title' => 'Note', | |
256 | 'description' => '', | |
257 | 'tags' => '', | |
258 | 'private' => '', | |
259 | 'created' => \DateTime::createFromFormat('Ymd_His', '20170107_160102'), | |
260 | ]; | |
e26e2060 A |
261 | $bookmark = new Bookmark(); |
262 | $bookmark->fromArray($data); | |
c3b00963 A |
263 | |
264 | $expected = [ | |
265 | 'id' => 12, | |
266 | 'url' => 'https://domain.tld/sub/?abc', | |
267 | 'shorturl' => 'abc', | |
268 | 'title' => 'Note', | |
269 | 'description' => '', | |
270 | 'tags' => [], | |
271 | 'private' => false, | |
272 | 'created' => '2017-01-07T16:01:02+00:00', | |
273 | 'updated' => '', | |
274 | ]; | |
275 | ||
e26e2060 | 276 | $this->assertEquals($expected, ApiUtils::formatLink($bookmark, $indexUrl)); |
c3b00963 | 277 | } |
cf9181dd A |
278 | |
279 | /** | |
280 | * Test updateLink with valid data, and also unnecessary fields. | |
281 | */ | |
282 | public function testUpdateLink() | |
283 | { | |
284 | $created = \DateTime::createFromFormat('Ymd_His', '20170107_160102'); | |
e26e2060 | 285 | $data = [ |
cf9181dd A |
286 | 'id' => 12, |
287 | 'url' => '?abc', | |
288 | 'shorturl' => 'abc', | |
289 | 'title' => 'Note', | |
290 | 'description' => '', | |
291 | 'tags' => '', | |
292 | 'private' => '', | |
293 | 'created' => $created, | |
294 | ]; | |
e26e2060 A |
295 | $old = new Bookmark(); |
296 | $old->fromArray($data); | |
cf9181dd | 297 | |
e26e2060 | 298 | $data = [ |
cf9181dd A |
299 | 'id' => 13, |
300 | 'shorturl' => 'nope', | |
301 | 'url' => 'http://somewhere.else', | |
302 | 'title' => 'Le Cid', | |
303 | 'description' => 'Percé jusques au fond du cœur [...]', | |
304 | 'tags' => 'corneille rodrigue', | |
305 | 'private' => true, | |
306 | 'created' => 'creation', | |
307 | 'updated' => 'updation', | |
308 | ]; | |
e26e2060 A |
309 | $new = new Bookmark(); |
310 | $new->fromArray($data); | |
cf9181dd A |
311 | |
312 | $result = ApiUtils::updateLink($old, $new); | |
e26e2060 A |
313 | $this->assertEquals(12, $result->getId()); |
314 | $this->assertEquals('http://somewhere.else', $result->getUrl()); | |
315 | $this->assertEquals('abc', $result->getShortUrl()); | |
316 | $this->assertEquals('Le Cid', $result->getTitle()); | |
317 | $this->assertEquals('Percé jusques au fond du cœur [...]', $result->getDescription()); | |
318 | $this->assertEquals('corneille rodrigue', $result->getTagsString()); | |
319 | $this->assertEquals(true, $result->isPrivate()); | |
320 | $this->assertEquals($created, $result->getCreated()); | |
cf9181dd A |
321 | } |
322 | ||
323 | /** | |
324 | * Test updateLink with minimal data. | |
325 | */ | |
326 | public function testUpdateLinkMinimal() | |
327 | { | |
328 | $created = \DateTime::createFromFormat('Ymd_His', '20170107_160102'); | |
e26e2060 | 329 | $data = [ |
cf9181dd A |
330 | 'id' => 12, |
331 | 'url' => '?abc', | |
332 | 'shorturl' => 'abc', | |
333 | 'title' => 'Note', | |
334 | 'description' => 'Interesting description!', | |
335 | 'tags' => 'doggo', | |
336 | 'private' => true, | |
337 | 'created' => $created, | |
338 | ]; | |
e26e2060 A |
339 | $old = new Bookmark(); |
340 | $old->fromArray($data); | |
cf9181dd | 341 | |
e26e2060 | 342 | $new = new Bookmark(); |
cf9181dd A |
343 | |
344 | $result = ApiUtils::updateLink($old, $new); | |
e26e2060 A |
345 | $this->assertEquals(12, $result->getId()); |
346 | $this->assertEquals('', $result->getUrl()); | |
347 | $this->assertEquals('abc', $result->getShortUrl()); | |
348 | $this->assertEquals('', $result->getTitle()); | |
349 | $this->assertEquals('', $result->getDescription()); | |
350 | $this->assertEquals('', $result->getTagsString()); | |
351 | $this->assertEquals(false, $result->isPrivate()); | |
352 | $this->assertEquals($created, $result->getCreated()); | |
cf9181dd | 353 | } |
18e67967 | 354 | } |