88use phpMyFAQ \Configuration ;
99use phpMyFAQ \Core \Exception ;
1010use phpMyFAQ \Database ;
11+ use phpMyFAQ \Database \DatabaseDriver ;
1112use phpMyFAQ \Database \Sqlite3 ;
1213use phpMyFAQ \Enums \PermissionType ;
1314use phpMyFAQ \Language ;
@@ -332,6 +333,76 @@ public function testCreateReturnsBadRequestForInvalidExpiresAtWhenAuthenticated(
332333 self ::assertSame ('Invalid expiresAt value. ' , $ payload ['error ' ]);
333334 }
334335
336+ /**
337+ * @throws \Exception
338+ */
339+ public function testCreateReturnsUnauthorizedForInvalidCsrfWhenAuthenticated (): void
340+ {
341+ $ controller = $ this ->createController ();
342+ $ controller ->setContainer ($ this ->createAuthenticatedContainer ());
343+
344+ $ response = $ controller ->create (new Request ([], [], [], [], [], [], json_encode ([
345+ 'csrf ' => 'invalid-token ' ,
346+ 'name ' => 'Generated key ' ,
347+ ], JSON_THROW_ON_ERROR )));
348+ $ payload = json_decode ((string ) $ response ->getContent (), true , 512 , JSON_THROW_ON_ERROR );
349+
350+ self ::assertSame (Response::HTTP_UNAUTHORIZED , $ response ->getStatusCode ());
351+ self ::assertSame (Translation::get ('msgNoPermission ' ), $ payload ['error ' ]);
352+ }
353+
354+ /**
355+ * @throws \Exception
356+ */
357+ public function testCreateReturnsBadRequestForMissingNameWhenAuthenticated (): void
358+ {
359+ $ controller = $ this ->createController ();
360+ $ container = $ this ->createAuthenticatedContainer ();
361+ $ session = $ container ->get ('session ' );
362+ self ::assertInstanceOf (Session::class, $ session );
363+ $ token = $ this ->createValidCsrfToken ($ session , 'api-key-create ' );
364+ $ controller ->setContainer ($ container );
365+
366+ $ response = $ controller ->create (new Request ([], [], [], [], [], [], json_encode ([
367+ 'csrf ' => $ token ,
368+ 'name ' => '' ,
369+ ], JSON_THROW_ON_ERROR )));
370+ $ payload = json_decode ((string ) $ response ->getContent (), true , 512 , JSON_THROW_ON_ERROR );
371+
372+ self ::assertSame (Response::HTTP_BAD_REQUEST , $ response ->getStatusCode ());
373+ self ::assertSame ('API key name is required. ' , $ payload ['error ' ]);
374+ }
375+
376+ /**
377+ * @throws \Exception
378+ */
379+ public function testCreateReturnsInternalServerErrorWhenInsertFails (): void
380+ {
381+ $ db = $ this ->createMock (DatabaseDriver::class);
382+ $ db ->method ('escape ' )->willReturnCallback (static fn (string $ value ): string => $ value );
383+ $ db ->method ('nextId ' )->willReturn (5 );
384+ $ db ->method ('now ' )->willReturn ("'2026-03-15 12:00:00' " );
385+ $ db ->method ('query ' )->willReturn (false );
386+ $ db ->method ('error ' )->willReturn ('insert failed ' );
387+
388+ $ controller = $ this ->createController ();
389+ $ container = $ this ->createAuthenticatedContainerWithDb ($ db );
390+ $ session = $ container ->get ('session ' );
391+ self ::assertInstanceOf (Session::class, $ session );
392+ $ token = $ this ->createValidCsrfToken ($ session , 'api-key-create ' );
393+ $ controller ->setContainer ($ container );
394+
395+ $ response = $ controller ->create (new Request ([], [], [], [], [], [], json_encode ([
396+ 'csrf ' => $ token ,
397+ 'name ' => 'Generated key ' ,
398+ 'scopes ' => ['faq.read ' ],
399+ ], JSON_THROW_ON_ERROR )));
400+ $ payload = json_decode ((string ) $ response ->getContent (), true , 512 , JSON_THROW_ON_ERROR );
401+
402+ self ::assertSame (Response::HTTP_INTERNAL_SERVER_ERROR , $ response ->getStatusCode ());
403+ self ::assertSame ('insert failed ' , $ payload ['error ' ]);
404+ }
405+
335406 /**
336407 * @throws \Exception
337408 */
@@ -386,6 +457,85 @@ public function testUpdateReturnsUpdatedApiKeyWhenAuthenticated(): void
386457 self ::assertSame ('2026-04-01 12:00:00 ' , $ payload ['expiresAt ' ]);
387458 }
388459
460+ /**
461+ * @throws \Exception
462+ */
463+ public function testUpdateReturnsBadRequestForMissingIdWhenAuthenticated (): void
464+ {
465+ $ controller = $ this ->createController ();
466+ $ container = $ this ->createAuthenticatedContainer ();
467+ $ session = $ container ->get ('session ' );
468+ self ::assertInstanceOf (Session::class, $ session );
469+ $ token = $ this ->createValidCsrfToken ($ session , 'api-key-update ' );
470+ $ controller ->setContainer ($ container );
471+
472+ $ response = $ controller ->update (new Request ([], [], [], [], [], [], json_encode ([
473+ 'csrf ' => $ token ,
474+ 'name ' => 'Updated key ' ,
475+ ], JSON_THROW_ON_ERROR )));
476+ $ payload = json_decode ((string ) $ response ->getContent (), true , 512 , JSON_THROW_ON_ERROR );
477+
478+ self ::assertSame (Response::HTTP_BAD_REQUEST , $ response ->getStatusCode ());
479+ self ::assertSame ('API key ID is required. ' , $ payload ['error ' ]);
480+ }
481+
482+ /**
483+ * @throws \Exception
484+ */
485+ public function testUpdateReturnsBadRequestForInvalidExpiresAtWhenAuthenticated (): void
486+ {
487+ $ this ->seedApiKeyRow ();
488+
489+ $ controller = $ this ->createController ();
490+ $ container = $ this ->createAuthenticatedContainer ();
491+ $ session = $ container ->get ('session ' );
492+ self ::assertInstanceOf (Session::class, $ session );
493+ $ token = $ this ->createValidCsrfToken ($ session , 'api-key-update ' );
494+ $ controller ->setContainer ($ container );
495+
496+ $ response = $ controller ->update (new Request ([], [], ['id ' => 1 ], [], [], [], json_encode ([
497+ 'csrf ' => $ token ,
498+ 'name ' => 'Updated key ' ,
499+ 'expiresAt ' => 'not-a-date ' ,
500+ ], JSON_THROW_ON_ERROR )));
501+ $ payload = json_decode ((string ) $ response ->getContent (), true , 512 , JSON_THROW_ON_ERROR );
502+
503+ self ::assertSame (Response::HTTP_BAD_REQUEST , $ response ->getStatusCode ());
504+ self ::assertSame ('Invalid expiresAt value. ' , $ payload ['error ' ]);
505+ }
506+
507+ /**
508+ * @throws \Exception
509+ */
510+ public function testUpdateReturnsInternalServerErrorWhenUpdateFails (): void
511+ {
512+ $ db = $ this ->createMock (DatabaseDriver::class);
513+ $ db ->method ('escape ' )->willReturnCallback (static fn (string $ value ): string => $ value );
514+ $ db ->method ('query ' )->willReturnMap ([
515+ [$ this ->stringContains ('SELECT id FROM faqapi_keys ' ), 0 , 0 , new \stdClass ()],
516+ [$ this ->stringContains ('UPDATE faqapi_keys ' ), 0 , 0 , false ],
517+ ]);
518+ $ db ->method ('numRows ' )->willReturn (1 );
519+ $ db ->method ('error ' )->willReturn ('update failed ' );
520+
521+ $ controller = $ this ->createController ();
522+ $ container = $ this ->createAuthenticatedContainerWithDb ($ db );
523+ $ session = $ container ->get ('session ' );
524+ self ::assertInstanceOf (Session::class, $ session );
525+ $ token = $ this ->createValidCsrfToken ($ session , 'api-key-update ' );
526+ $ controller ->setContainer ($ container );
527+
528+ $ response = $ controller ->update (new Request ([], [], ['id ' => 1 ], [], [], [], json_encode ([
529+ 'csrf ' => $ token ,
530+ 'name ' => 'Updated key ' ,
531+ 'scopes ' => ['faq.read ' ],
532+ ], JSON_THROW_ON_ERROR )));
533+ $ payload = json_decode ((string ) $ response ->getContent (), true , 512 , JSON_THROW_ON_ERROR );
534+
535+ self ::assertSame (Response::HTTP_INTERNAL_SERVER_ERROR , $ response ->getStatusCode ());
536+ self ::assertSame ('update failed ' , $ payload ['error ' ]);
537+ }
538+
389539 /**
390540 * @throws \Exception
391541 */
@@ -403,6 +553,31 @@ public function testDeleteReturnsUnauthorizedForInvalidCsrfWhenAuthenticated():
403553 self ::assertSame (Translation::get ('msgNoPermission ' ), $ payload ['error ' ]);
404554 }
405555
556+ /**
557+ * @throws \Exception
558+ */
559+ public function testDeleteReturnsInternalServerErrorWhenDeleteFails (): void
560+ {
561+ $ db = $ this ->createMock (DatabaseDriver::class);
562+ $ db ->method ('query ' )->willReturn (false );
563+ $ db ->method ('error ' )->willReturn ('delete failed ' );
564+
565+ $ controller = $ this ->createController ();
566+ $ container = $ this ->createAuthenticatedContainerWithDb ($ db );
567+ $ session = $ container ->get ('session ' );
568+ self ::assertInstanceOf (Session::class, $ session );
569+ $ token = $ this ->createValidCsrfToken ($ session , 'api-key-delete ' );
570+ $ controller ->setContainer ($ container );
571+
572+ $ response = $ controller ->delete (new Request ([], [], ['id ' => 1 ], [], [], [], json_encode ([
573+ 'csrf ' => $ token ,
574+ ], JSON_THROW_ON_ERROR )));
575+ $ payload = json_decode ((string ) $ response ->getContent (), true , 512 , JSON_THROW_ON_ERROR );
576+
577+ self ::assertSame (Response::HTTP_INTERNAL_SERVER_ERROR , $ response ->getStatusCode ());
578+ self ::assertSame ('delete failed ' , $ payload ['error ' ]);
579+ }
580+
406581 private function seedApiKeyRow (): void
407582 {
408583 $ this ->dbHandle ->query ('DELETE FROM faqapi_keys ' );
@@ -425,6 +600,11 @@ private function createValidCsrfToken(Session $session, string $page): string
425600 }
426601
427602 private function createAuthenticatedContainer (): ContainerInterface
603+ {
604+ return $ this ->createAuthenticatedContainerWithDb ($ this ->configuration ->getDb ());
605+ }
606+
607+ private function createAuthenticatedContainerWithDb (DatabaseDriver $ db ): ContainerInterface
428608 {
429609 $ permission = $ this ->createMock (PermissionInterface::class);
430610 $ permission
@@ -443,13 +623,17 @@ private function createAuthenticatedContainer(): ContainerInterface
443623
444624 $ session = new Session (new MockArraySessionStorage ());
445625 $ adminLog = $ this ->createStub (AdminLog::class);
626+ $ configuration = $ this ->createMock (Configuration::class);
627+ $ configuration ->method ('getDb ' )->willReturn ($ db );
628+ $ configuration ->method ('get ' )->willReturn (false );
629+ $ configuration ->method ('getTemplateSet ' )->willReturn ('default ' );
446630
447631 $ container = $ this ->createStub (ContainerInterface::class);
448632 $ container
449633 ->method ('get ' )
450- ->willReturnCallback (function (string $ id ) use ($ currentUser , $ session , $ adminLog ) {
634+ ->willReturnCallback (function (string $ id ) use ($ currentUser , $ session , $ adminLog, $ configuration ) {
451635 return match ($ id ) {
452- 'phpmyfaq.configuration ' => $ this -> configuration ,
636+ 'phpmyfaq.configuration ' => $ configuration ,
453637 'phpmyfaq.user.current_user ' => $ currentUser ,
454638 'session ' => $ session ,
455639 'phpmyfaq.admin.admin-log ' => $ adminLog ,
0 commit comments