From b56c22ba5cc820b8e47392b954a16f74c07d6d4d Mon Sep 17 00:00:00 2001 From: Herpaderp Aldent Date: Fri, 15 May 2026 06:49:02 +0200 Subject: [PATCH 1/8] refactor: replace CheckAccess with assertScope() from EsiTransportInterface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope enforcement now lives where scope is defined. Each generated execute() calls $transport->assertScope(self::REQUIRED_SCOPE) before any HTTP call. The transport (EsiClient) implements the check against the JWT token. Changes: - EsiClient: implement assertScope(?string $scope): void - null = public endpoint → no-op - non-null = must be present in JWT scopes → ScopeAccessDeniedException - EsiClient: remove CheckAccess wiring from constructor and withToken() - EsiClient: remove hasAccess() pre-flight from invoke() - EsiScopeAccessDeniedException: now extends ScopeAccessDeniedException from esi-schema for backward compatibility - Delete CheckAccess.php (250-line hardcoded scope map — no longer needed) - Delete CheckAccessTest.php (replaced by assertScope tests in EsiClientTest) - Bump seatplus/esi-schema: ^1.1 (requires assertScope in EsiTransportInterface) - Update GeneratedResourcesTest: use real JWT tokens, remove CheckAccess mock - Update EsiClientTest: replace access-denied test with assertScope tests Requires seatplus/esi-schema ^1.1 (PR #4). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- composer.json | 2 +- src/EsiClient.php | 42 +-- .../EsiScopeAccessDeniedException.php | 9 +- src/Services/CheckAccess.php | 257 ------------------ tests/Unit/Access/CheckAccessTest.php | 54 ---- tests/Unit/EsiClientTest.php | 42 ++- tests/Unit/GeneratedResourcesTest.php | 16 +- 7 files changed, 71 insertions(+), 351 deletions(-) delete mode 100644 src/Services/CheckAccess.php delete mode 100644 tests/Unit/Access/CheckAccessTest.php diff --git a/composer.json b/composer.json index c6d239b..e259468 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "kevinrob/guzzle-cache-middleware": "^4.0", "monolog/monolog": "^3.7", "nesbot/carbon": "^3.0", - "seatplus/esi-schema": "^1.0" + "seatplus/esi-schema": "^1.1" }, "require-dev": { "ext-openssl": "*", diff --git a/src/EsiClient.php b/src/EsiClient.php index 43f46d6..4d4ff90 100644 --- a/src/EsiClient.php +++ b/src/EsiClient.php @@ -5,16 +5,15 @@ use GuzzleHttp\Psr7\Uri; use Psr\Http\Message\UriInterface; use Seatplus\EsiClient\DataTransferObjects\EsiAuthentication; -use Seatplus\EsiClient\Exceptions\EsiScopeAccessDeniedException; use Seatplus\EsiClient\Exceptions\InvalidAuthenticationException; use Seatplus\EsiClient\Exceptions\RequestFailedException; use Seatplus\EsiClient\Exceptions\UriDataMissingException; use Seatplus\EsiClient\Fetcher\GuzzleFetcher; use Seatplus\EsiClient\Log\LogInterface; -use Seatplus\EsiClient\Services\CheckAccess; use Seatplus\EsiSchema\Contracts\EsiCursor; use Seatplus\EsiSchema\Contracts\EsiRawResponse; use Seatplus\EsiSchema\Contracts\EsiTransportInterface; +use Seatplus\EsiSchema\Contracts\ScopeAccessDeniedException; use Seatplus\EsiSchema\Resources\AllianceResource; use Seatplus\EsiSchema\Resources\AssetsResource; use Seatplus\EsiSchema\Resources\CalendarResource; @@ -60,11 +59,9 @@ class EsiClient implements EsiTransportInterface public function __construct( private ?EsiAuthentication $authentication = null, private ?GuzzleFetcher $fetcher = null, - private ?CheckAccess $checkAccess = null ) { $this->fetcher ??= $this->createFetcher(); $this->logger = $this->createLogger(); - $this->checkAccess ??= new CheckAccess($this->authentication); } /** @@ -81,7 +78,6 @@ public function withToken(string $accessToken): static refresh_token: '', ); $clone->fetcher = $clone->createFetcher(); - $clone->checkAccess = new CheckAccess($clone->authentication); return $clone; } @@ -268,7 +264,7 @@ private function createFetcher(): GuzzleFetcher * @throws \Throwable * @throws UriDataMissingException * @throws InvalidAuthenticationException - * @throws EsiScopeAccessDeniedException + * @throws ScopeAccessDeniedException */ public function invoke( string $method, @@ -279,15 +275,6 @@ public function invoke( ): EsiRawResponse { // Enrich the uri $uri = $this->buildDataUri($path, $pathValues, $queryParams); - - // First check if access requirements are met - if (! $this->hasAccess($method, $path)) { - // Log the deny. - $this->logger->warning("Access denied to {$uri} due to missing scopes."); - throw new EsiScopeAccessDeniedException("Access denied to {$uri}"); - } - - // Fetcher will take care of caching $response = $this->fetcher->call($method, $uri, $requestBody); // Extract cursor tokens if the response body contains a `cursor` object. @@ -312,6 +299,26 @@ public function invoke( ); } + /** + * Assert that the current token possesses the required OAuth2 scope. + * Null = public endpoint — no-op. + * + * @throws ScopeAccessDeniedException + */ + public function assertScope(?string $scope): void + { + if ($scope === null) { + return; + } + + $scopes = $this->authentication?->getScopes() ?? []; + + if (! in_array($scope, $scopes, true)) { + $this->logger->warning("Scope check failed: {$scope} not in token."); + throw new ScopeAccessDeniedException($scope); + } + } + private function createLogger(): LogInterface { return $this->getConfiguration()->getLogger(); @@ -367,9 +374,4 @@ private function mapDataToUri(string $uri, array $data): string return $uri; } - - private function hasAccess(string $method, string $uri_original): bool - { - return $this->checkAccess->can($method, $uri_original); - } } diff --git a/src/Exceptions/EsiScopeAccessDeniedException.php b/src/Exceptions/EsiScopeAccessDeniedException.php index 94c2b08..0d18c8f 100644 --- a/src/Exceptions/EsiScopeAccessDeniedException.php +++ b/src/Exceptions/EsiScopeAccessDeniedException.php @@ -2,4 +2,11 @@ namespace Seatplus\EsiClient\Exceptions; -class EsiScopeAccessDeniedException extends \Exception {} +use Seatplus\EsiSchema\Contracts\ScopeAccessDeniedException; + +/** + * @deprecated Use \Seatplus\EsiSchema\Contracts\ScopeAccessDeniedException directly. + * This subclass is kept for backward compatibility with code that catches + * EsiScopeAccessDeniedException by name. + */ +class EsiScopeAccessDeniedException extends ScopeAccessDeniedException {} diff --git a/src/Services/CheckAccess.php b/src/Services/CheckAccess.php deleted file mode 100644 index 583e3e7..0000000 --- a/src/Services/CheckAccess.php +++ /dev/null @@ -1,257 +0,0 @@ - [ - // 'meta' URI's. see: https://esi.evetech.net/ui/?version=meta - '/ping' => 'public', - // Generated using tools: php get_endpoints_and_scopes.php - '/alliances/' => 'public', - '/alliances/{alliance_id}/' => 'public', - '/alliances/{alliance_id}/contacts/' => 'esi-alliances.read_contacts.v1', - '/alliances/{alliance_id}/contacts/labels/' => 'esi-alliances.read_contacts.v1', - '/alliances/{alliance_id}/corporations/' => 'public', - '/alliances/{alliance_id}/icons/' => 'public', - '/characters/{character_id}/' => 'public', - '/characters/{character_id}/agents_research/' => 'esi-characters.read_agents_research.v1', - '/characters/{character_id}/assets/' => 'esi-assets.read_assets.v1', - '/characters/{character_id}/attributes/' => 'esi-skills.read_skills.v1', - '/characters/{character_id}/blueprints/' => 'esi-characters.read_blueprints.v1', - '/characters/{character_id}/bookmarks/' => 'esi-bookmarks.read_character_bookmarks.v1', - '/characters/{character_id}/bookmarks/folders/' => 'esi-bookmarks.read_character_bookmarks.v1', - '/characters/{character_id}/calendar/' => 'esi-calendar.read_calendar_events.v1', - '/characters/{character_id}/calendar/{event_id}/' => 'esi-calendar.read_calendar_events.v1', - '/characters/{character_id}/calendar/{event_id}/attendees/' => 'esi-calendar.read_calendar_events.v1', - '/characters/{character_id}/clones/' => 'esi-clones.read_clones.v1', - '/characters/{character_id}/contacts/' => 'esi-characters.read_contacts.v1', - '/characters/{character_id}/contacts/labels/' => 'esi-characters.read_contacts.v1', - '/characters/{character_id}/contracts/' => 'esi-contracts.read_character_contracts.v1', - '/characters/{character_id}/contracts/{contract_id}/bids/' => 'esi-contracts.read_character_contracts.v1', - '/characters/{character_id}/contracts/{contract_id}/items/' => 'esi-contracts.read_character_contracts.v1', - '/characters/{character_id}/corporationhistory/' => 'public', - '/characters/{character_id}/fatigue/' => 'esi-characters.read_fatigue.v1', - '/characters/{character_id}/fittings/' => 'esi-fittings.read_fittings.v1', - '/characters/{character_id}/fleet/' => 'esi-fleets.read_fleet.v1', - '/characters/{character_id}/fw/stats/' => 'esi-characters.read_fw_stats.v1', - '/characters/{character_id}/implants/' => 'esi-clones.read_implants.v1', - '/characters/{character_id}/industry/jobs/' => 'esi-industry.read_character_jobs.v1', - '/characters/{character_id}/killmails/recent/' => 'esi-killmails.read_killmails.v1', - '/characters/{character_id}/location/' => 'esi-location.read_location.v1', - '/characters/{character_id}/loyalty/points/' => 'esi-characters.read_loyalty.v1', - '/characters/{character_id}/mail/' => 'esi-mail.read_mail.v1', - '/characters/{character_id}/mail/labels/' => 'esi-mail.read_mail.v1', - '/characters/{character_id}/mail/lists/' => 'esi-mail.read_mail.v1', - '/characters/{character_id}/mail/{mail_id}/' => 'esi-mail.read_mail.v1', - '/characters/{character_id}/medals/' => 'esi-characters.read_medals.v1', - '/characters/{character_id}/mining/' => 'esi-industry.read_character_mining.v1', - '/characters/{character_id}/notifications/' => 'esi-characters.read_notifications.v1', - '/characters/{character_id}/notifications/contacts/' => 'esi-characters.read_notifications.v1', - '/characters/{character_id}/online/' => 'esi-location.read_online.v1', - '/characters/{character_id}/opportunities/' => 'esi-characters.read_opportunities.v1', - '/characters/{character_id}/orders/' => 'esi-markets.read_character_orders.v1', - '/characters/{character_id}/orders/history/' => 'esi-markets.read_character_orders.v1', - '/characters/{character_id}/planets/' => 'esi-planets.manage_planets.v1', - '/characters/{character_id}/planets/{planet_id}/' => 'esi-planets.manage_planets.v1', - '/characters/{character_id}/portrait/' => 'public', - '/characters/{character_id}/roles/' => 'esi-characters.read_corporation_roles.v1', - '/characters/{character_id}/search/' => 'esi-search.search_structures.v1', - '/characters/{character_id}/ship/' => 'esi-location.read_ship_type.v1', - '/characters/{character_id}/skillqueue/' => 'esi-skills.read_skillqueue.v1', - '/characters/{character_id}/skills/' => 'esi-skills.read_skills.v1', - '/characters/{character_id}/standings/' => 'esi-characters.read_standings.v1', - '/characters/{character_id}/titles/' => 'esi-characters.read_titles.v1', - '/characters/{character_id}/wallet/' => 'esi-wallet.read_character_wallet.v1', - '/characters/{character_id}/wallet/journal/' => 'esi-wallet.read_character_wallet.v1', - '/characters/{character_id}/wallet/transactions/' => 'esi-wallet.read_character_wallet.v1', - '/contracts/public/bids/{contract_id}/' => 'public', - '/contracts/public/items/{contract_id}/' => 'public', - '/contracts/public/{region_id}/' => 'public', - '/corporation/{corporation_id}/mining/extractions/' => 'esi-industry.read_corporation_mining.v1', - '/corporation/{corporation_id}/mining/observers/' => 'esi-industry.read_corporation_mining.v1', - '/corporation/{corporation_id}/mining/observers/{observer_id}/' => 'esi-industry.read_corporation_mining.v1', - '/corporations/npccorps/' => 'public', - '/corporations/{corporation_id}/' => 'public', - '/corporations/{corporation_id}/alliancehistory/' => 'public', - '/corporations/{corporation_id}/assets/' => 'esi-assets.read_corporation_assets.v1', - '/corporations/{corporation_id}/blueprints/' => 'esi-corporations.read_blueprints.v1', - '/corporations/{corporation_id}/bookmarks/' => 'esi-bookmarks.read_corporation_bookmarks.v1', - '/corporations/{corporation_id}/bookmarks/folders/' => 'esi-bookmarks.read_corporation_bookmarks.v1', - '/corporations/{corporation_id}/contacts/' => 'esi-corporations.read_contacts.v1', - '/corporations/{corporation_id}/contacts/labels/' => 'esi-corporations.read_contacts.v1', - '/corporations/{corporation_id}/containers/logs/' => 'esi-corporations.read_container_logs.v1', - '/corporations/{corporation_id}/contracts/' => 'esi-contracts.read_corporation_contracts.v1', - '/corporations/{corporation_id}/contracts/{contract_id}/bids/' => 'esi-contracts.read_corporation_contracts.v1', - '/corporations/{corporation_id}/contracts/{contract_id}/items/' => 'esi-contracts.read_corporation_contracts.v1', - '/corporations/{corporation_id}/customs_offices/' => 'esi-planets.read_customs_offices.v1', - '/corporations/{corporation_id}/divisions/' => 'esi-corporations.read_divisions.v1', - '/corporations/{corporation_id}/facilities/' => 'esi-corporations.read_facilities.v1', - '/corporations/{corporation_id}/fw/stats/' => 'esi-corporations.read_fw_stats.v1', - '/corporations/{corporation_id}/icons/' => 'public', - '/corporations/{corporation_id}/industry/jobs/' => 'esi-industry.read_corporation_jobs.v1', - '/corporations/{corporation_id}/killmails/recent/' => 'esi-killmails.read_corporation_killmails.v1', - '/corporations/{corporation_id}/medals/' => 'esi-corporations.read_medals.v1', - '/corporations/{corporation_id}/medals/issued/' => 'esi-corporations.read_medals.v1', - '/corporations/{corporation_id}/members/' => 'esi-corporations.read_corporation_membership.v1', - '/corporations/{corporation_id}/members/limit/' => 'esi-corporations.track_members.v1', - '/corporations/{corporation_id}/members/titles/' => 'esi-corporations.read_titles.v1', - '/corporations/{corporation_id}/membertracking/' => 'esi-corporations.track_members.v1', - '/corporations/{corporation_id}/orders/' => 'esi-markets.read_corporation_orders.v1', - '/corporations/{corporation_id}/orders/history/' => 'esi-markets.read_corporation_orders.v1', - '/corporations/{corporation_id}/roles/' => 'esi-corporations.read_corporation_membership.v1', - '/corporations/{corporation_id}/roles/history/' => 'esi-corporations.read_corporation_membership.v1', - '/corporations/{corporation_id}/shareholders/' => 'esi-wallet.read_corporation_wallets.v1', - '/corporations/{corporation_id}/standings/' => 'esi-corporations.read_standings.v1', - '/corporations/{corporation_id}/starbases/' => 'esi-corporations.read_starbases.v1', - '/corporations/{corporation_id}/starbases/{starbase_id}/' => 'esi-corporations.read_starbases.v1', - '/corporations/{corporation_id}/structures/' => 'esi-corporations.read_structures.v1', - '/corporations/{corporation_id}/titles/' => 'esi-corporations.read_titles.v1', - '/corporations/{corporation_id}/wallets/' => 'esi-wallet.read_corporation_wallets.v1', - '/corporations/{corporation_id}/wallets/{division}/journal/' => 'esi-wallet.read_corporation_wallets.v1', - '/corporations/{corporation_id}/wallets/{division}/transactions/' => 'esi-wallet.read_corporation_wallets.v1', - '/dogma/attributes/' => 'public', - '/dogma/attributes/{attribute_id}/' => 'public', - '/dogma/dynamic/items/{type_id}/{item_id}/' => 'public', - '/dogma/effects/' => 'public', - '/dogma/effects/{effect_id}/' => 'public', - '/fleets/{fleet_id}/' => 'esi-fleets.read_fleet.v1', - '/fleets/{fleet_id}/members/' => 'esi-fleets.read_fleet.v1', - '/fleets/{fleet_id}/wings/' => 'esi-fleets.read_fleet.v1', - '/fw/leaderboards/' => 'public', - '/fw/leaderboards/characters/' => 'public', - '/fw/leaderboards/corporations/' => 'public', - '/fw/stats/' => 'public', - '/fw/systems/' => 'public', - '/fw/wars/' => 'public', - '/incursions/' => 'public', - '/industry/facilities/' => 'public', - '/industry/systems/' => 'public', - '/insurance/prices/' => 'public', - '/killmails/{killmail_id}/{killmail_hash}/' => 'public', - '/loyalty/stores/{corporation_id}/offers/' => 'public', - '/markets/groups/' => 'public', - '/markets/groups/{market_group_id}/' => 'public', - '/markets/prices/' => 'public', - '/markets/structures/{structure_id}/' => 'esi-markets.structure_markets.v1', - '/markets/{region_id}/history/' => 'public', - '/markets/{region_id}/orders/' => 'public', - '/markets/{region_id}/types/' => 'public', - '/opportunities/groups/' => 'public', - '/opportunities/groups/{group_id}/' => 'public', - '/opportunities/tasks/' => 'public', - '/opportunities/tasks/{task_id}/' => 'public', - '/route/{origin}/{destination}/' => 'public', - '/sovereignty/campaigns/' => 'public', - '/sovereignty/map/' => 'public', - '/sovereignty/structures/' => 'public', - '/status/' => 'public', - '/universe/ancestries/' => 'public', - '/universe/asteroid_belts/{asteroid_belt_id}/' => 'public', - '/universe/bloodlines/' => 'public', - '/universe/categories/' => 'public', - '/universe/categories/{category_id}/' => 'public', - '/universe/constellations/' => 'public', - '/universe/constellations/{constellation_id}/' => 'public', - '/universe/factions/' => 'public', - '/universe/graphics/' => 'public', - '/universe/graphics/{graphic_id}/' => 'public', - '/universe/groups/' => 'public', - '/universe/groups/{group_id}/' => 'public', - '/universe/moons/{moon_id}/' => 'public', - '/universe/planets/{planet_id}/' => 'public', - '/universe/races/' => 'public', - '/universe/regions/' => 'public', - '/universe/regions/{region_id}/' => 'public', - '/universe/schematics/{schematic_id}/' => 'public', - '/universe/stargates/{stargate_id}/' => 'public', - '/universe/stars/{star_id}/' => 'public', - '/universe/stations/{station_id}/' => 'public', - '/universe/structures/' => 'public', - '/universe/structures/{structure_id}/' => 'esi-universe.read_structures.v1', - '/universe/system_jumps/' => 'public', - '/universe/system_kills/' => 'public', - '/universe/systems/' => 'public', - '/universe/systems/{system_id}/' => 'public', - '/universe/types/' => 'public', - '/universe/types/{type_id}/' => 'public', - '/wars/' => 'public', - '/wars/{war_id}/' => 'public', - '/wars/{war_id}/killmails/' => 'public', - ], - 'post' => [ - '/characters/affiliation/' => 'public', - '/characters/{character_id}/assets/locations/' => 'esi-assets.read_assets.v1', - '/characters/{character_id}/assets/names/' => 'esi-assets.read_assets.v1', - '/characters/{character_id}/contacts/' => 'esi-characters.write_contacts.v1', - '/characters/{character_id}/cspa/' => 'esi-characters.read_contacts.v1', - '/characters/{character_id}/fittings/' => 'esi-fittings.write_fittings.v1', - '/characters/{character_id}/mail/' => 'esi-mail.send_mail.v1', - '/characters/{character_id}/mail/labels/' => 'esi-mail.organize_mail.v1', - '/corporations/{corporation_id}/assets/locations/' => 'esi-assets.read_corporation_assets.v1', - '/corporations/{corporation_id}/assets/names/' => 'esi-assets.read_corporation_assets.v1', - '/fleets/{fleet_id}/members/' => 'esi-fleets.write_fleet.v1', - '/fleets/{fleet_id}/wings/' => 'esi-fleets.write_fleet.v1', - '/fleets/{fleet_id}/wings/{wing_id}/squads/' => 'esi-fleets.write_fleet.v1', - '/ui/autopilot/waypoint/' => 'esi-ui.write_waypoint.v1', - '/ui/openwindow/contract/' => 'esi-ui.open_window.v1', - '/ui/openwindow/information/' => 'esi-ui.open_window.v1', - '/ui/openwindow/marketdetails/' => 'esi-ui.open_window.v1', - '/ui/openwindow/newmail/' => 'esi-ui.open_window.v1', - '/universe/ids/' => 'public', - '/universe/names/' => 'public', - ], - 'put' => [ - '/characters/{character_id}/calendar/{event_id}/' => 'esi-calendar.respond_calendar_events.v1', - '/characters/{character_id}/contacts/' => 'esi-characters.write_contacts.v1', - '/characters/{character_id}/mail/{mail_id}/' => 'esi-mail.organize_mail.v1', - '/fleets/{fleet_id}/' => 'esi-fleets.write_fleet.v1', - '/fleets/{fleet_id}/members/{member_id}/' => 'esi-fleets.write_fleet.v1', - '/fleets/{fleet_id}/squads/{squad_id}/' => 'esi-fleets.write_fleet.v1', - '/fleets/{fleet_id}/wings/{wing_id}/' => 'esi-fleets.write_fleet.v1', - ], - 'delete' => [ - '/characters/{character_id}/contacts/' => 'esi-characters.write_contacts.v1', - '/characters/{character_id}/fittings/{fitting_id}/' => 'esi-fittings.write_fittings.v1', - '/characters/{character_id}/mail/labels/{label_id}/' => 'esi-mail.organize_mail.v1', - '/characters/{character_id}/mail/{mail_id}/' => 'esi-mail.organize_mail.v1', - '/fleets/{fleet_id}/members/{member_id}/' => 'esi-fleets.write_fleet.v1', - '/fleets/{fleet_id}/squads/{squad_id}/' => 'esi-fleets.write_fleet.v1', - '/fleets/{fleet_id}/wings/{wing_id}/' => 'esi-fleets.write_fleet.v1', - ], - 'patch' => [ - ], - ]; - - public function __construct(private readonly ?EsiAuthentication $authentication = null) {} - - public function can(string $method, string $uri): bool - { - // make $method lowercase - $method = strtolower($method); - - if (! array_key_exists($uri, $this->scope_map[$method])) { - EsiConfiguration::getInstance()->getLogger() - ->warning('An unknown URI was called. Allowing '.$uri); - - return true; - } - - $required_scope = $this->scope_map[$method][$uri]; - - // Public scopes require no authentication! - if ($required_scope == 'public') { - return true; - } - - if (! in_array($required_scope, $this->authentication->getScopes())) { - return false; - } - - return true; - } -} diff --git a/tests/Unit/Access/CheckAccessTest.php b/tests/Unit/Access/CheckAccessTest.php deleted file mode 100644 index aa8fcbd..0000000 --- a/tests/Unit/Access/CheckAccessTest.php +++ /dev/null @@ -1,54 +0,0 @@ - $this->check_access = new CheckAccess); - -test('CheckAccess object initiation', function () { - expect($this->check_access)->toBeInstanceOf(CheckAccess::class); -}); - -it('grants access if scope is present', function () { - $authentication = buildEsiAuthentication([ - 'access_token' => json_encode([ - 'scp' => ['esi-assets.read_assets.v1'], - ]), - ]); - - $check_access = new CheckAccess($authentication); - - $result = $check_access->can('get', '/characters/{character_id}/assets/'); - - expect($result)->toBeTrue(); -}); - -it('denies access if scope is missing', function () { - $authentication = buildEsiAuthentication([ - 'access_token' => json_encode([ - 'scp' => ['esi-assets.read_assets.v1'], - ]), - ]); - - $check_access = new CheckAccess($authentication); - - $result = $check_access->can('get', '/characters/{character_id}/bookmarks/'); - - expect($result)->toBeFalse(); -}); - -it('allows public only call', function () { - $result = $this->check_access->can('get', '/alliances/'); - - expect($result)->toBeTrue(); -}); - -it('allows unknown url calls', function () { - // Disable logging. - EsiConfiguration::getInstance(logger: NullLogger::class); - - $result = $this->check_access->can('get', '/invalid/uri'); - - $this->assertTrue($result); -}); diff --git a/tests/Unit/EsiClientTest.php b/tests/Unit/EsiClientTest.php index 155bf17..736df56 100644 --- a/tests/Unit/EsiClientTest.php +++ b/tests/Unit/EsiClientTest.php @@ -1,15 +1,13 @@ fetcherMock = mock(GuzzleFetcher::class); @@ -37,18 +35,38 @@ expect(fn () => $this->client->invoke('GET', $uri))->toThrow(UriDataMissingException::class); }); -it('throws exception for access denied', function () { - $authentication = new EsiAuthentication('token', 'refresh_token'); +it('assertScope passes for null (public endpoint)', function () { + $client = new EsiClient; - $checkAccess = mock(CheckAccess::class, function (MockInterface $mock) { - $mock->shouldReceive('can') - ->once() - ->andReturnFalse(); - }); + // Should not throw for public endpoints + $client->assertScope(null); + expect(true)->toBeTrue(); +}); + +it('assertScope throws when scope is missing from token', function () { + $token = buildJWT(json_encode(['scp' => ['esi-assets.read_assets.v1']])); + $authentication = new EsiAuthentication($token, ''); + $client = new EsiClient($authentication, $this->fetcherMock); + + expect(fn () => $client->assertScope('esi-mail.read_mail.v1')) + ->toThrow(ScopeAccessDeniedException::class); +}); + +it('assertScope passes when scope is present in token', function () { + $token = buildJWT(json_encode(['scp' => ['esi-assets.read_assets.v1']])); + $authentication = new EsiAuthentication($token, ''); + $client = new EsiClient($authentication, $this->fetcherMock); + + // Should not throw + $client->assertScope('esi-assets.read_assets.v1'); + expect(true)->toBeTrue(); +}); - $client = new EsiClient($authentication, $this->fetcherMock, $checkAccess); +it('assertScope throws when authentication is null', function () { + $client = new EsiClient(null, $this->fetcherMock); - expect(fn () => $client->invoke('GET', '/test/uri'))->toThrow(EsiScopeAccessDeniedException::class); + expect(fn () => $client->assertScope('esi-assets.read_assets.v1')) + ->toThrow(ScopeAccessDeniedException::class); }); it('builds correct data URI', function () { diff --git a/tests/Unit/GeneratedResourcesTest.php b/tests/Unit/GeneratedResourcesTest.php index 5a63a59..07e36b4 100644 --- a/tests/Unit/GeneratedResourcesTest.php +++ b/tests/Unit/GeneratedResourcesTest.php @@ -4,7 +4,6 @@ use Seatplus\EsiClient\DataTransferObjects\EsiResponse; use Seatplus\EsiClient\EsiClient; use Seatplus\EsiClient\Fetcher\GuzzleFetcher; -use Seatplus\EsiClient\Services\CheckAccess; use Seatplus\EsiSchema\EsiResult; use Seatplus\EsiSchema\Resources\AllianceResource; use Seatplus\EsiSchema\Resources\CharacterResource; @@ -18,13 +17,18 @@ function makeEsiResponse(string $raw, array $headers = []): EsiResponse return new EsiResponse($raw, $headers, 'now', 200); } -/** Build a client whose CheckAccess always returns true (no JWT decode needed). */ -function makeAuthedClient(GuzzleFetcher $fetcher): EsiClient +/** + * Build a client with a real JWT token that includes the given scopes. + * Scope enforcement now happens in assertScope() — no more CheckAccess mock needed. + */ +function makeAuthedClient(GuzzleFetcher $fetcher, array $scopes = ['esi-assets.read_assets.v1', 'esi-universe.read_structures.v1', 'esi-characters.read_characters.v1']): EsiClient { - $checkAccess = mock(CheckAccess::class); - $checkAccess->shouldReceive('can')->andReturn(true); + $token = buildJWT(json_encode(['scp' => $scopes])); - return new EsiClient(new EsiAuthentication('tok', ''), $fetcher, $checkAccess); + return new EsiClient( + new EsiAuthentication($token, ''), + $fetcher, + ); } // --------------------------------------------------------------------------- From e7d0df166c0d5900886629fc817ef2b1bb131b10 Mon Sep 17 00:00:00 2001 From: Herpaderp Aldent Date: Fri, 15 May 2026 07:19:57 +0200 Subject: [PATCH 2/8] chore: use dev-feat/assert-scope alias until esi-schema 1.1.0 is tagged Until esi-schema PR #4 is merged and 1.1.0 tagged on Packagist, use the branch dev alias so CI can resolve the dependency. Will be reverted to ^1.1 once tag exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- composer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e259468..2d645b9 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "kevinrob/guzzle-cache-middleware": "^4.0", "monolog/monolog": "^3.7", "nesbot/carbon": "^3.0", - "seatplus/esi-schema": "^1.1" + "seatplus/esi-schema": "dev-feat/assert-scope as 1.1.0" }, "require-dev": { "ext-openssl": "*", @@ -74,5 +74,7 @@ "allow-plugins": { "pestphp/pest-plugin": true } - } + }, + "minimum-stability": "dev", + "prefer-stable": true } From c1e41806520d2c41c37a90af0926fe294e10fe50 Mon Sep 17 00:00:00 2001 From: Herpaderp Aldent Date: Fri, 15 May 2026 07:38:19 +0200 Subject: [PATCH 3/8] =?UTF-8?q?chore:=20trigger=20CI=20=E2=80=94=20add=20a?= =?UTF-8?q?ssertScope=20docblock,=20watch=20composer.json=20in=20formats?= =?UTF-8?q?=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add docblock to EsiClient::assertScope() explaining null semantics - Update formats.yml to also trigger on composer.json changes and pull_request targeting feat/esi-rate-limit-overhaul Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/formats.yml | 3 ++- src/EsiClient.php | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/formats.yml b/.github/workflows/formats.yml index 6731470..e785735 100644 --- a/.github/workflows/formats.yml +++ b/.github/workflows/formats.yml @@ -4,8 +4,9 @@ on: push: paths: - '**.php' + - 'composer.json' pull_request: - branches: [ 3.x, 4.x ] + branches: [ 3.x, 4.x, feat/esi-rate-limit-overhaul ] jobs: ci: diff --git a/src/EsiClient.php b/src/EsiClient.php index 4d4ff90..889ec3d 100644 --- a/src/EsiClient.php +++ b/src/EsiClient.php @@ -305,6 +305,12 @@ public function invoke( * * @throws ScopeAccessDeniedException */ + /** + * Verify the authenticated token contains the required scope. + * + * Null means a public endpoint — always passes. + * Non-null throws ScopeAccessDeniedException if the scope is absent from the JWT. + */ public function assertScope(?string $scope): void { if ($scope === null) { From 16a2dd261d00bc067ca05197991ee50b95a96fbb Mon Sep 17 00:00:00 2001 From: Herpaderp Aldent Date: Fri, 15 May 2026 07:41:01 +0200 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20remove=20minimum-stability=3Ddev=20?= =?UTF-8?q?=E2=80=94=20explicit=20dev-branch=20constraint=20is=20sufficien?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit dev-* constraints override minimum-stability per-package, so setting minimum-stability=dev globally was unnecessary and caused pest-plugin-type-coverage to resolve a dev build instead of stable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- composer.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 2d645b9..11e5255 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,5 @@ "allow-plugins": { "pestphp/pest-plugin": true } - }, - "minimum-stability": "dev", - "prefer-stable": true + } } From 30bebe92e24b0bd18bc93a36a2a18da508c1cf3f Mon Sep 17 00:00:00 2001 From: Herpaderp Aldent Date: Fri, 15 May 2026 21:19:40 +0200 Subject: [PATCH 5/8] chore: use stable seatplus/esi-schema ^1.1 (1.1.0 now tagged on Packagist) Replaces the temporary dev-feat/assert-scope alias now that esi-schema PR #4 has been merged and tagged 1.1.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 11e5255..e259468 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "kevinrob/guzzle-cache-middleware": "^4.0", "monolog/monolog": "^3.7", "nesbot/carbon": "^3.0", - "seatplus/esi-schema": "dev-feat/assert-scope as 1.1.0" + "seatplus/esi-schema": "^1.1" }, "require-dev": { "ext-openssl": "*", From e4c088f7ea4ffb88ea78aae7bd5acdaf19af40c1 Mon Sep 17 00:00:00 2001 From: Herpaderp Aldent Date: Fri, 15 May 2026 21:54:20 +0200 Subject: [PATCH 6/8] chore: remove feature branch name from formats.yml pull_request trigger Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/formats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/formats.yml b/.github/workflows/formats.yml index e785735..ca13ac1 100644 --- a/.github/workflows/formats.yml +++ b/.github/workflows/formats.yml @@ -6,7 +6,7 @@ on: - '**.php' - 'composer.json' pull_request: - branches: [ 3.x, 4.x, feat/esi-rate-limit-overhaul ] + branches: [ 3.x, 4.x ] jobs: ci: From 220f0d475736cfbf9c5d4b77e1a5b22d68702670 Mon Sep 17 00:00:00 2001 From: Herpaderp Aldent Date: Fri, 15 May 2026 22:11:18 +0200 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20drop=20EsiScopeAccessDeniedExcepti?= =?UTF-8?q?on=20=E2=80=94=20use=20ScopeAccessDeniedException=20from=20esi-?= =?UTF-8?q?schema=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No backwards compatibility needed. Callers should catch Seatplus\EsiSchema\Contracts\ScopeAccessDeniedException. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Exceptions/EsiScopeAccessDeniedException.php | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 src/Exceptions/EsiScopeAccessDeniedException.php diff --git a/src/Exceptions/EsiScopeAccessDeniedException.php b/src/Exceptions/EsiScopeAccessDeniedException.php deleted file mode 100644 index 0d18c8f..0000000 --- a/src/Exceptions/EsiScopeAccessDeniedException.php +++ /dev/null @@ -1,12 +0,0 @@ - Date: Sat, 16 May 2026 07:01:39 +0200 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20remove=20/latest/=20URL=20prefix=20?= =?UTF-8?q?=E2=80=94=20use=20server+path=20per=20ESI=20OpenAPI=203.1=20spe?= =?UTF-8?q?c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ESI OpenAPI 3.1 spec (esi.evetech.net/meta/openapi.yaml) defines: servers: [url: https://esi.evetech.net] paths: /alliances, /characters/{character_id}/assets, … No version prefix anywhere. Versioning is handled by X-Compatibility-Date header (already sent by GuzzleFetcher). The /latest/ prefix was a Swagger 2.0 basePath artifact. Changes: - buildDataUri(): /{path}/ instead of /latest/{path}/ - EsiConfiguration: default compatibility_date to '2025-12-16' (matches esi-schema generation date; update when regenerating) - Update tests accordingly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/EsiClient.php | 2 +- src/EsiConfiguration.php | 6 +++--- tests/Unit/EsiClientTest.php | 2 +- tests/Unit/EsiConfigurationTest.php | 6 +++--- tests/Unit/Fetcher/GuzzleFetcherTest.php | 5 ++++- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/EsiClient.php b/src/EsiClient.php index 889ec3d..869e260 100644 --- a/src/EsiClient.php +++ b/src/EsiClient.php @@ -345,7 +345,7 @@ private function buildDataUri(string $uri, array $data, array $query_parameters) $query_params = array_merge(['datasource' => $this->getConfiguration('datasource')], $query_parameters); $path = sprintf( - '/latest/%s/', + '/%s/', trim($this->mapDataToUri($uri, $data), '/') ); diff --git a/src/EsiConfiguration.php b/src/EsiConfiguration.php index 4d81f55..689be36 100644 --- a/src/EsiConfiguration.php +++ b/src/EsiConfiguration.php @@ -46,9 +46,9 @@ public function __construct( public string $fetcher = GuzzleFetcher::class, // Versioning — X-Compatibility-Date header value (YYYY-MM-DD). - // Only needed for new-style ESI endpoints without a URL version prefix. - // Leave null for all existing versioned endpoints (/v5/...). - public ?string $compatibility_date = null, + // Sent on every request. Matches the ESI OpenAPI spec compatibility date + // used to generate seatplus/esi-schema. Update when regenerating the schema. + public ?string $compatibility_date = '2025-12-16', ) {} public static function getInstance(...$args): self diff --git a/tests/Unit/EsiClientTest.php b/tests/Unit/EsiClientTest.php index 736df56..83206b3 100644 --- a/tests/Unit/EsiClientTest.php +++ b/tests/Unit/EsiClientTest.php @@ -76,7 +76,7 @@ $uri = $method->invokeArgs($this->client, ['/test/uri/{id}', ['id' => 123], ['param' => 'value']]); expect($uri)->toBeInstanceOf(Uri::class) - ->and((string) $uri)->toBe('https://esi.evetech.net/latest/test/uri/123/?datasource=tranquility¶m=value'); + ->and((string) $uri)->toBe('https://esi.evetech.net/test/uri/123/?datasource=tranquility¶m=value'); }); it('throws exception for missing data', function () { diff --git a/tests/Unit/EsiConfigurationTest.php b/tests/Unit/EsiConfigurationTest.php index 0387874..cebb6e6 100644 --- a/tests/Unit/EsiConfigurationTest.php +++ b/tests/Unit/EsiConfigurationTest.php @@ -22,7 +22,7 @@ ->and($config->log_max_files)->toBe(10) ->and($config->cache_middleware)->toBe(NullCacheMiddleware::class) ->and($config->fetcher)->toBe(GuzzleFetcher::class) - ->and($config->compatibility_date)->toBeNull(); + ->and($config->compatibility_date)->toBe('2025-12-16'); }); it('singleton instance is consistent', function () { @@ -62,11 +62,11 @@ expect($config->compatibility_date)->toBe('2025-10-01'); }); -it('compatibility_date defaults to null', function () { +it('compatibility_date defaults to 2025-12-16', function () { EsiConfiguration::resetInstance(); $config = EsiConfiguration::getInstance(); - expect($config->compatibility_date)->toBeNull(); + expect($config->compatibility_date)->toBe('2025-12-16'); EsiConfiguration::resetInstance(); }); diff --git a/tests/Unit/Fetcher/GuzzleFetcherTest.php b/tests/Unit/Fetcher/GuzzleFetcherTest.php index 3d978c3..5ba0c7d 100644 --- a/tests/Unit/Fetcher/GuzzleFetcherTest.php +++ b/tests/Unit/Fetcher/GuzzleFetcherTest.php @@ -186,9 +186,12 @@ EsiConfiguration::resetInstance(); }); -it('does not send X-Compatibility-Date header when not configured', function () { +it('does not send X-Compatibility-Date header when compatibility_date is null', function () { $sentHeaders = []; + EsiConfiguration::resetInstance(); + EsiConfiguration::getInstance(compatibility_date: null); + $mock = new MockHandler([ new Response(200, [], json_encode(['foo' => 'bar'])), ]);