diff --git a/.github/workflows/nightly_unit_tests.yml b/.github/workflows/nightly_unit_tests.yml index 01dd2112..5e5671b0 100644 --- a/.github/workflows/nightly_unit_tests.yml +++ b/.github/workflows/nightly_unit_tests.yml @@ -32,6 +32,9 @@ jobs: SSL_ENABLED: false SESSION_DRIVER: redis PHP_VERSION: 8.3 + FACEBOOK_CLIENT_ID: 214242500242860 + FACEBOOK_CLIENT_SECRET: e62fa81aa898699d8cebf14bf5e586aa + FACEBOOK_REDIRECT_URI: /auth/login/facebook/callback services: mysql: image: mysql:8.0 diff --git a/.github/workflows/pull_request_unit_tests.yml b/.github/workflows/pull_request_unit_tests.yml index 462317c3..c4a35288 100644 --- a/.github/workflows/pull_request_unit_tests.yml +++ b/.github/workflows/pull_request_unit_tests.yml @@ -37,6 +37,9 @@ jobs: PHP_VERSION: 8.3 OTEL_SDK_DISABLED: true OTEL_SERVICE_ENABLED: false + FACEBOOK_CLIENT_ID: 214242500242860 + FACEBOOK_CLIENT_SECRET: e62fa81aa898699d8cebf14bf5e586aa + FACEBOOK_REDIRECT_URI: /auth/login/facebook/callback services: mysql: image: mysql:8.0 diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ad2ede65..001d6d0a 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -33,6 +33,9 @@ jobs: PHP_VERSION: 8.3 OTEL_SDK_DISABLED: true OTEL_SERVICE_ENABLED: false + FACEBOOK_CLIENT_ID: 214242500242860 + FACEBOOK_CLIENT_SECRET: e62fa81aa898699d8cebf14bf5e586aa + FACEBOOK_REDIRECT_URI: /auth/login/facebook/callback services: mysql: image: mysql:8.0 diff --git a/app/Console/Commands/FacebookDataDeletionBackfill.php b/app/Console/Commands/FacebookDataDeletionBackfill.php new file mode 100644 index 00000000..0d39afb2 --- /dev/null +++ b/app/Console/Commands/FacebookDataDeletionBackfill.php @@ -0,0 +1,108 @@ +service = $service; + } + + /** + * @return int + */ + public function handle() + { + $path = $this->argument('path'); + + if (!is_readable($path)) { + $this->error(sprintf("File %s is not readable.", $path)); + return 1; + } + + $matched = 0; + $not_found = 0; + $skipped = 0; + + $handle = @fopen($path, 'r'); + if ($handle === false) { + $this->error(sprintf("Unable to open file %s.", $path)); + return 1; + } + + while (($line = fgets($handle)) !== false) { + $external_id = trim($line, " \t\n\r\0\x0B\"'"); + + if ($external_id === '') { + $skipped++; + continue; + } + + $result = $this->service->processDeletionRequest($external_id); + + if ($result['status'] === 'completed') { + $matched++; + } else { + $not_found++; + } + + Log::debug(sprintf("FacebookDataDeletionBackfill::handle processed request with status %s", $result['status'])); + } + fclose($handle); + + $this->info(sprintf("Processed CSV: %d matched, %d not found, %d skipped blank lines.", $matched, $not_found, $skipped)); + return 0; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 89bf376a..cdabd3d2 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -29,6 +29,7 @@ class Kernel extends ConsoleKernel Commands\CleanOAuth2StaleData::class, Commands\CleanOpenIdStaleData::class, Commands\CreateSuperAdmin::class, + Commands\FacebookDataDeletionBackfill::class, Commands\SpammerProcess\RebuildUserSpammerEstimator::class, Commands\SpammerProcess\UserSpammerProcessor::class, ]; diff --git a/app/Http/Controllers/Api/FacebookDataDeletionController.php b/app/Http/Controllers/Api/FacebookDataDeletionController.php new file mode 100644 index 00000000..a64bddcc --- /dev/null +++ b/app/Http/Controllers/Api/FacebookDataDeletionController.php @@ -0,0 +1,91 @@ +service = $service; + } + + /** + * @param Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function handle(Request $request) + { + $signed_request = $request->input('signed_request'); + + if (!is_string($signed_request) || $signed_request === '') { + Log::warning("FacebookDataDeletionController::handle missing signed_request"); + return response()->json([ + 'error' => ['code' => 'invalid_request', 'message' => 'signed_request is required.'] + ], 400); + } + + $secret = Config::get('services.facebook.client_secret'); + $data = FacebookSignedRequestParser::parse($signed_request, $secret); + + if (is_null($data)) { + Log::warning("FacebookDataDeletionController::handle invalid signed_request"); + return response()->json([ + 'error' => ['code' => 'invalid_signature', 'message' => 'Invalid signed_request.'] + ], 400); + } + + $result = $this->service->processDeletionRequest((string)$data['user_id']); + + return response()->json([ + 'url' => $result['url'], + 'confirmation_code' => $result['confirmation_code'], + ], 200); + } + + /** + * @param Request $request + * @param string $confirmation_code + * @return \Illuminate\Contracts\View\View + */ + public function status(Request $request, string $confirmation_code) + { + $result = $this->service->getStatus($confirmation_code); + + if (is_null($result)) { + abort(404); + } + + return view('auth.facebook_data_deletion_status', ['result' => $result]); + } +} diff --git a/app/Repositories/DoctrineUserRepository.php b/app/Repositories/DoctrineUserRepository.php index f6f8e8e3..408a3f15 100644 --- a/app/Repositories/DoctrineUserRepository.php +++ b/app/Repositories/DoctrineUserRepository.php @@ -140,4 +140,22 @@ public function getByIdWithGroups(int $id): ?User return $qb->getQuery()->getOneOrNullResult(); } + + /** + * @param string $provider + * @param string $external_id + * @return User|null + */ + public function getByExternalId(string $provider, string $external_id): ?User + { + return $this->getEntityManager() + ->createQueryBuilder() + ->select("e") + ->from($this->getBaseEntity(), "e") + ->Where("e.external_provider = :provider AND e.external_id = :external_id") + ->setParameter("provider", $provider) + ->setParameter("external_id", $external_id) + ->getQuery() + ->getOneOrNullResult(); + } } \ No newline at end of file diff --git a/app/Services/Auth/FacebookDataDeletionService.php b/app/Services/Auth/FacebookDataDeletionService.php new file mode 100644 index 00000000..1153b4d2 --- /dev/null +++ b/app/Services/Auth/FacebookDataDeletionService.php @@ -0,0 +1,165 @@ +user_repository = $user_repository; + } + + /** + * Resolved fresh on every call (never cached on the instance), matching + * DoctrineRepository::getEntityManager() - DoctrineTransactionService::transaction() + * can swap in a new EntityManager mid-request on a retryable connection error + * (Registry::resetManager()), and a cached reference would then point at a + * closed connection. + * @return EntityManagerInterface + */ + private function getEntityManager(): EntityManagerInterface + { + return Registry::getManager(BaseEntity::EntityManager); + } + + /** + * @inheritDoc + */ + public function processDeletionRequest(string $external_id, string $provider = 'facebook'): array + { + return $this->tx_service->transaction(function () use ($external_id, $provider) { + + $existing = $this->findRequest($provider, $external_id); + if (!is_null($existing)) { + return $this->toResult($existing); + } + + $user = $this->user_repository->getByExternalId($provider, $external_id); + + $status = self::StatusNotFound; + $user_id = null; + + if (!is_null($user)) { + $user->setExternalId(null); + $user->setExternalProvider(null); + $user->setExternalPic(null); + $user_id = $user->getId(); + $status = self::StatusCompleted; + } + + $confirmation_code = (new RandomGenerator())->randomToken('sha256'); + + // Inserted through the same Doctrine DBAL connection the user-entity + // flush below uses (via $this->tx_service->transaction()), so both + // writes commit or roll back together - using Laravel's separately + // autocommitting `DB` facade connection here would let this row + // persist even if the flush that actually nulls the user's fields + // fails afterward. + try { + $this->getEntityManager()->getConnection()->insert(self::Table, [ + 'provider' => $provider, + 'external_id' => $external_id, + 'confirmation_code' => $confirmation_code, + 'status' => $status, + 'user_id' => $user_id, + 'created_at' => now()->format('Y-m-d H:i:s'), + 'updated_at' => now()->format('Y-m-d H:i:s'), + ]); + } catch (UniqueConstraintViolationException $ex) { + // a concurrent request already inserted the row for this + // (provider, external_id) pair before this one committed - + // two independent statements racing the same unique index. + // Treat this as the idempotent path instead of failing the request. + $existing = $this->findRequest($provider, $external_id); + if (!is_null($existing)) { + return $this->toResult($existing); + } + throw $ex; + } + + return $this->toResult((object)[ + 'confirmation_code' => $confirmation_code, + 'status' => $status, + ]); + }); + } + + /** + * @inheritDoc + */ + public function getStatus(string $confirmation_code): ?array + { + $row = $this->getEntityManager()->getConnection()->fetchAssociative( + 'SELECT * FROM ' . self::Table . ' WHERE confirmation_code = ?', + [$confirmation_code] + ); + return $row === false ? null : $row; + } + + /** + * @param string $provider + * @param string $external_id + * @return object|null + */ + private function findRequest(string $provider, string $external_id) + { + $row = $this->getEntityManager()->getConnection()->fetchAssociative( + 'SELECT * FROM ' . self::Table . ' WHERE provider = ? AND external_id = ?', + [$provider, $external_id] + ); + return $row === false ? null : (object)$row; + } + + /** + * @param object $row + * @return array + */ + private function toResult(object $row): array + { + return [ + 'confirmation_code' => $row->confirmation_code, + 'status' => $row->status, + 'url' => URL::route('facebook_data_deletion_status', ['confirmation_code' => $row->confirmation_code]), + ]; + } +} diff --git a/app/Services/Auth/IFacebookDataDeletionService.php b/app/Services/Auth/IFacebookDataDeletionService.php new file mode 100644 index 00000000..e8e8744b --- /dev/null +++ b/app/Services/Auth/IFacebookDataDeletionService.php @@ -0,0 +1,37 @@ +external_provider = $external_provider; } @@ -2207,25 +2207,25 @@ public function getExternalPic(): ?string } /** - * @param string $external_pic + * @param string|null $external_pic */ - public function setExternalPic(string $external_pic): void + public function setExternalPic(?string $external_pic): void { $this->external_pic = $external_pic; } /** - * @return string + * @return string|null */ - public function getExternalId(): string + public function getExternalId(): ?string { return $this->external_id; } /** - * @param string $external_id + * @param string|null $external_id */ - public function setExternalId(string $external_id): void + public function setExternalId(?string $external_id): void { $this->external_id = $external_id; } diff --git a/app/libs/Auth/Repositories/IUserRepository.php b/app/libs/Auth/Repositories/IUserRepository.php index acf4d316..0127cec9 100644 --- a/app/libs/Auth/Repositories/IUserRepository.php +++ b/app/libs/Auth/Repositories/IUserRepository.php @@ -45,4 +45,11 @@ public function getByIdentifier($user_identifier):?User; public function getByVerificationEmailToken(string $token):?User; public function getByIdWithGroups(int $id): ?User; + + /** + * @param string $provider + * @param string $external_id + * @return User|null + */ + public function getByExternalId(string $provider, string $external_id): ?User; } \ No newline at end of file diff --git a/database/migrations/Version20260806190000.php b/database/migrations/Version20260806190000.php new file mode 100644 index 00000000..01d22401 --- /dev/null +++ b/database/migrations/Version20260806190000.php @@ -0,0 +1,58 @@ +hasTable("facebook_deletion_requests")) { + $builder->create("facebook_deletion_requests", function (Table $table) { + $table->increments('id'); + $table->timestamps(); + $table->string("provider")->setNotnull(true); + $table->string("external_id")->setNotnull(true); + $table->string("confirmation_code")->setNotnull(true); + $table->string("status")->setNotnull(true); + $table->bigInteger("user_id")->setUnsigned(true)->setNotnull(false); + $table->index("user_id", "user_id"); + $table->foreign("users", "user_id", "id", ["onDelete" => "SET NULL"]); + $table->unique(["provider", "external_id"]); + $table->unique("confirmation_code"); + }); + } + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema):void + { + $builder = new Builder($schema); + + $builder->dropIfExists("facebook_deletion_requests"); + } +} diff --git a/resources/views/auth/facebook_data_deletion_status.blade.php b/resources/views/auth/facebook_data_deletion_status.blade.php new file mode 100644 index 00000000..cfa8d53d --- /dev/null +++ b/resources/views/auth/facebook_data_deletion_status.blade.php @@ -0,0 +1,22 @@ +@extends('layout') +@section('title') + Welcome to {{ Config::get("app.app_name") }} - Facebook Data Deletion Status +@append +@section('scripts') + +@append + +@section('content') +
+
+

Facebook Data Deletion Request Status

+

Confirmation code: {{ $result['confirmation_code'] }}

+ @if($result['status'] === 'completed') +

Your Facebook-linked data has been deleted.

+ @else +

No data was found for this identifier.

+ @endif +
+
+@endsection diff --git a/routes/api_public.php b/routes/api_public.php index c3fcd7b5..82b4b2c0 100644 --- a/routes/api_public.php +++ b/routes/api_public.php @@ -12,6 +12,7 @@ * limitations under the License. **/ +use App\Http\Controllers\Api\FacebookDataDeletionController; use Illuminate\Support\Facades\Route; /* @@ -48,3 +49,11 @@ return response()->json($versionData, 200); }); + +Route::post('/facebook/data-deletion', [FacebookDataDeletionController::class, 'handle']) + ->middleware('throttle:60,1') + ->name('facebook_data_deletion_callback'); + +Route::get('/facebook/data-deletion/status/{confirmation_code}', [FacebookDataDeletionController::class, 'status']) + ->middleware('throttle:60,1') + ->name('facebook_data_deletion_status'); diff --git a/tests/FacebookDataDeletionApiTest.php b/tests/FacebookDataDeletionApiTest.php new file mode 100644 index 00000000..31ec4576 --- /dev/null +++ b/tests/FacebookDataDeletionApiTest.php @@ -0,0 +1,157 @@ +delete(); + } + + private function buildSignedRequest(string $user_id): string + { + $secret = Config::get('services.facebook.client_secret'); + $payload = ['algorithm' => 'HMAC-SHA256', 'user_id' => $user_id]; + $encoded_payload = $this->base64UrlEncode(json_encode($payload)); + $sig = hash_hmac('sha256', $encoded_payload, $secret, true); + $encoded_sig = $this->base64UrlEncode($sig); + return $encoded_sig . '.' . $encoded_payload; + } + + private function base64UrlEncode(string $input): string + { + return rtrim(strtr(base64_encode($input), '+/', '-_'), '='); + } + + private function linkSeededUserToFacebook(string $asid): User + { + $user_repository = EntityManager::getRepository(User::class); + $user = $user_repository->findOneBy(["identifier" => 'sebastian.marcet']); + $user->setExternalId($asid); + $user->setExternalProvider('facebook'); + $user->setExternalPic('https://graph.facebook.com/pic.jpg'); + EntityManager::persist($user); + EntityManager::flush(); + return $user; + } + + public function testMatchedUserIsUnlinkedAndReturnsConfirmation(): void + { + $asid = '218471001'; + $user = $this->linkSeededUserToFacebook($asid); + $sr = $this->buildSignedRequest($asid); + + $response = $this->post(self::CallbackUri, ['signed_request' => $sr]); + + $this->assertResponseStatus(200); + $json = json_decode($response->response->getContent(), true); + $this->assertNotEmpty($json['confirmation_code']); + $this->assertNotEmpty($json['url']); + + $reloaded = EntityManager::getRepository(User::class)->getById($user->getId()); + $this->assertNull($reloaded->getExternalId()); + $this->assertNull($reloaded->getExternalProvider()); + $this->assertNull($reloaded->getExternalPic()); + + $row = DB::table('facebook_deletion_requests')->where('external_id', $asid)->first(); + $this->assertNotNull($row); + $this->assertSame('completed', $row->status); + $this->assertSame($user->getId(), $row->user_id); + } + + public function testUnmatchedAsidReturns200AndPersistsNotFoundRow(): void + { + $asid = 'no-such-user-999999'; + $sr = $this->buildSignedRequest($asid); + + $response = $this->post(self::CallbackUri, ['signed_request' => $sr]); + + $this->assertResponseStatus(200); + $json = json_decode($response->response->getContent(), true); + $this->assertNotEmpty($json['confirmation_code']); + + $row = DB::table('facebook_deletion_requests')->where('external_id', $asid)->first(); + $this->assertNotNull($row); + $this->assertSame('not_found', $row->status); + $this->assertNull($row->user_id); + } + + public function testTamperedSignatureReturns400(): void + { + $sr = $this->buildSignedRequest('218471001'); + [, $payload] = explode('.', $sr, 2); + $tampered = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.' . $payload; + + $response = $this->post(self::CallbackUri, ['signed_request' => $tampered]); + + $this->assertResponseStatus(400); + } + + public function testStatusPageForRealConfirmationCodeReturns200(): void + { + $asid = '218471002'; + $sr = $this->buildSignedRequest($asid); + $response = $this->post(self::CallbackUri, ['signed_request' => $sr]); + $json = json_decode($response->response->getContent(), true); + $confirmation_code = $json['confirmation_code']; + + $status_response = $this->get(self::CallbackUri . '/status/' . $confirmation_code); + + $this->assertResponseStatus(200); + $status_response->see($confirmation_code); + } + + public function testArraySignedRequestReturns400InsteadOf500(): void + { + $response = $this->post(self::CallbackUri, ['signed_request' => ['a', 'b']]); + + $this->assertResponseStatus(400); + } + + public function testStatusPageForUnknownCodeReturns404(): void + { + $this->get(self::CallbackUri . '/status/does-not-exist'); + + $this->assertResponseStatus(404); + } + + public function testDuplicateSubmissionIsIdempotent(): void + { + $asid = '218471003'; + $sr = $this->buildSignedRequest($asid); + + $first = $this->post(self::CallbackUri, ['signed_request' => $sr]); + $first_json = json_decode($first->response->getContent(), true); + + $second = $this->post(self::CallbackUri, ['signed_request' => $sr]); + $second_json = json_decode($second->response->getContent(), true); + + $this->assertResponseStatus(200); + $this->assertSame($first_json['confirmation_code'], $second_json['confirmation_code']); + + $count = DB::table('facebook_deletion_requests')->where('external_id', $asid)->count(); + $this->assertSame(1, $count); + } +} diff --git a/tests/FacebookDataDeletionBackfillCommandTest.php b/tests/FacebookDataDeletionBackfillCommandTest.php new file mode 100644 index 00000000..a56ae29a --- /dev/null +++ b/tests/FacebookDataDeletionBackfillCommandTest.php @@ -0,0 +1,147 @@ +delete(); + } + + private function linkSeededUserToFacebook(string $asid): User + { + $user_repository = EntityManager::getRepository(User::class); + $user = $user_repository->findOneBy(["identifier" => 'sebastian.marcet']); + $user->setExternalId($asid); + $user->setExternalProvider('facebook'); + EntityManager::persist($user); + EntityManager::flush(); + return $user; + } + + private function writeCsvFixture(array $lines): string + { + $path = tempnam(sys_get_temp_dir(), 'fb_deletion_csv_'); + file_put_contents($path, implode("\n", $lines) . "\n"); + return $path; + } + + public function testBackfillUnlinksMatchedAndRecordsUnmatched(): void + { + $matched_asid = '555000111'; + $unmatched_asid = '555000222'; + $user = $this->linkSeededUserToFacebook($matched_asid); + + $csv_path = $this->writeCsvFixture([$matched_asid, $unmatched_asid, '']); + + $exit_code = Artisan::call(self::Command, ['path' => $csv_path]); + + $this->assertSame(0, $exit_code); + + $reloaded = EntityManager::getRepository(User::class)->getById($user->getId()); + $this->assertNull($reloaded->getExternalId()); + + $matched_row = DB::table('facebook_deletion_requests')->where('external_id', $matched_asid)->first(); + $this->assertSame('completed', $matched_row->status); + + $unmatched_row = DB::table('facebook_deletion_requests')->where('external_id', $unmatched_asid)->first(); + $this->assertSame('not_found', $unmatched_row->status); + + unlink($csv_path); + } + + public function testBackfillIsIdempotentAcrossRuns(): void + { + $asid = '555000333'; + $csv_path = $this->writeCsvFixture([$asid]); + + Artisan::call(self::Command, ['path' => $csv_path]); + Artisan::call(self::Command, ['path' => $csv_path]); + + $count = DB::table('facebook_deletion_requests')->where('external_id', $asid)->count(); + $this->assertSame(1, $count); + + unlink($csv_path); + } + + public function testDoesNotLogExternalId(): void + { + Log::spy(); + $asid = '555000444'; + $csv_path = $this->writeCsvFixture([$asid]); + + Artisan::call(self::Command, ['path' => $csv_path]); + + Log::shouldNotHaveReceived('debug', function (string $message) use ($asid) { + return str_contains($message, $asid); + }); + + unlink($csv_path); + } + + public function testUnreadablePathReturnsNonZeroExitCode(): void + { + $exit_code = Artisan::call(self::Command, ['path' => '/no/such/file.csv']); + + $this->assertNotSame(0, $exit_code); + } + + public function testFopenFailureAfterReadableCheckReturnsNonZeroExitCode(): void + { + stream_wrapper_register('fbfailtest', FacebookDataDeletionBackfillFailingStreamWrapper::class); + + try { + $exit_code = Artisan::call(self::Command, ['path' => 'fbfailtest://fake.csv']); + $this->assertNotSame(0, $exit_code); + } finally { + stream_wrapper_unregister('fbfailtest'); + } + } +} + +/** + * Reports the path as readable (url_stat succeeds) but fails to open it + * (stream_open returns false) - reproduces the fopen()-fails-after- + * is_readable()-passes gap without depending on real filesystem races. + */ +final class FacebookDataDeletionBackfillFailingStreamWrapper +{ + public $context; + + public function url_stat(string $path, int $flags) + { + return [ + 'dev' => 0, 'ino' => 0, 'mode' => 0100644, 'nlink' => 1, + 'uid' => 0, 'gid' => 0, 'rdev' => 0, 'size' => 0, + 'atime' => 0, 'mtime' => 0, 'ctime' => 0, 'blksize' => -1, 'blocks' => -1, + ]; + } + + public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool + { + return false; + } +} diff --git a/tests/unit/FacebookDataDeletionServiceRaceTest.php b/tests/unit/FacebookDataDeletionServiceRaceTest.php new file mode 100644 index 00000000..a180efca --- /dev/null +++ b/tests/unit/FacebookDataDeletionServiceRaceTest.php @@ -0,0 +1,80 @@ +createMock(IUserRepository::class); + $user_repository->method('getByExternalId')->willReturn(null); + + $connection = $this->createMock(Connection::class); + + $lookup_calls = 0; + $connection->method('fetchAssociative') + ->willReturnCallback(function () use (&$lookup_calls) { + $lookup_calls++; + if ($lookup_calls === 1) { + // first check: no row yet, so processDeletionRequest proceeds to insert + return false; + } + // second check (inside the catch): a concurrent writer already + // committed the row for this (provider, external_id) pair + return [ + 'provider' => 'facebook', + 'external_id' => 'racing-asid', + 'confirmation_code' => 'winner-confirmation-code', + 'status' => 'not_found', + 'user_id' => null, + ]; + }); + + $connection->method('insert') + ->willThrowException($this->createMock(UniqueConstraintViolationException::class)); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getConnection')->willReturn($connection); + + $registry = $this->createMock(ManagerRegistry::class); + $registry->method('getManager')->willReturn($em); + Registry::swap($registry); + + $tx_service = $this->createMock(ITransactionService::class); + $tx_service->method('transaction')->willReturnCallback(fn($callback) => $callback()); + + $service = new FacebookDataDeletionService($user_repository, $tx_service); + + $result = $service->processDeletionRequest('racing-asid'); + + $this->assertSame('winner-confirmation-code', $result['confirmation_code']); + $this->assertSame('not_found', $result['status']); + } +} diff --git a/tests/unit/FacebookSignedRequestParserTest.php b/tests/unit/FacebookSignedRequestParserTest.php new file mode 100644 index 00000000..66bf327f --- /dev/null +++ b/tests/unit/FacebookSignedRequestParserTest.php @@ -0,0 +1,109 @@ +base64UrlEncode(json_encode($payload)); + $sig = hash_hmac('sha256', $encoded_payload, $secret, true); + $encoded_sig = $this->base64UrlEncode($sig); + return $encoded_sig . '.' . $encoded_payload; + } + + private function base64UrlEncode(string $input): string + { + return rtrim(strtr(base64_encode($input), '+/', '-_'), '='); + } + + public function testValidSignedRequestReturnsPayloadWithUserId(): void + { + $sr = $this->buildSignedRequest(['user_id' => '218471'], self::Secret); + + $data = FacebookSignedRequestParser::parse($sr, self::Secret); + + $this->assertIsArray($data); + $this->assertSame('218471', $data['user_id']); + } + + public function testLowercaseAlgorithmIsStillAccepted(): void + { + $sr = $this->buildSignedRequest(['user_id' => '218471'], self::Secret, 'hmac-sha256'); + + $data = FacebookSignedRequestParser::parse($sr, self::Secret); + + $this->assertIsArray($data); + $this->assertSame('218471', $data['user_id']); + } + + public function testTamperedPayloadReturnsNull(): void + { + $sr = $this->buildSignedRequest(['user_id' => '218471'], self::Secret); + [$sig, $payload] = explode('.', $sr, 2); + + $tampered_payload = $this->base64UrlEncode(json_encode(['user_id' => '999999', 'algorithm' => 'HMAC-SHA256'])); + $tampered_sr = $sig . '.' . $tampered_payload; + + $this->assertNull(FacebookSignedRequestParser::parse($tampered_sr, self::Secret)); + } + + public function testWrongSecretReturnsNull(): void + { + $sr = $this->buildSignedRequest(['user_id' => '218471'], self::Secret); + + $this->assertNull(FacebookSignedRequestParser::parse($sr, 'wrong-secret')); + } + + public function testUnsupportedAlgorithmReturnsNull(): void + { + $sr = $this->buildSignedRequest(['user_id' => '218471'], self::Secret, 'MD5'); + + $this->assertNull(FacebookSignedRequestParser::parse($sr, self::Secret)); + } + + public function testMalformedStringWithNoDotReturnsNull(): void + { + $this->assertNull(FacebookSignedRequestParser::parse('not-a-valid-signed-request', self::Secret)); + } + + public function testMissingUserIdReturnsNull(): void + { + $sr = $this->buildSignedRequest([], self::Secret); + + $this->assertNull(FacebookSignedRequestParser::parse($sr, self::Secret)); + } + + public function testNullSecretReturnsNullInsteadOfThrowing(): void + { + $sr = $this->buildSignedRequest(['user_id' => '218471'], self::Secret); + + $this->assertNull(FacebookSignedRequestParser::parse($sr, null)); + } + + public function testEmptySecretReturnsNullInsteadOfThrowing(): void + { + $sr = $this->buildSignedRequest(['user_id' => '218471'], self::Secret); + + $this->assertNull(FacebookSignedRequestParser::parse($sr, '')); + } +}