feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Auth;
use Grav\Common\Yaml;
use Grav\Plugin\Api\Auth\ApiKeyAuthenticator;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for ApiKeyAuthenticator.
*
* API keys live in the central user/data/api-keys.yaml store (keyed by id,
* each entry carrying its owning `username`). The authenticator looks a raw
* key up in that store, then loads the matching account. Tests seed the store
* via a temp-dir `user://data` locator and provide a matching accounts mock.
*/
#[CoversClass(ApiKeyAuthenticator::class)]
class ApiKeyAuthenticatorTest extends TestCase
{
private const RAW_KEY = 'grav_test_api_key_raw_value_1234';
private string $dataDir;
protected function setUp(): void
{
$this->dataDir = sys_get_temp_dir() . '/grav_api_authn_test_' . uniqid();
@mkdir($this->dataDir, 0775, true);
$this->resetKeysCache();
}
protected function tearDown(): void
{
$this->resetKeysCache();
$this->rmrf($this->dataDir);
}
private function resetKeysCache(): void
{
(new \ReflectionProperty(ApiKeyManager::class, 'keysCache'))->setValue(null, null);
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) {
return;
}
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
/**
* Build an authenticator with the central key store seeded and the given
* accounts available.
*
* @param array<string, array> $keys central store entries keyed by id
* @param array<string, object> $users accounts keyed by username
*/
private function buildAuthenticator(array $keys, array $users = []): ApiKeyAuthenticator
{
$dataDir = $this->dataDir;
$locator = new class ($dataDir) {
public function __construct(private string $dir) {}
public function findResource(string $uri, bool $absolute = true, bool $first = false): string
{
return $this->dir;
}
};
$accounts = TestHelper::createMockAccounts($users);
$grav = TestHelper::createMockGrav(['accounts' => $accounts, 'locator' => $locator]);
// Seed the central store after the Grav container exists.
file_put_contents($this->dataDir . '/api-keys.yaml', Yaml::dump($keys));
$this->resetKeysCache();
return new ApiKeyAuthenticator($grav);
}
#[Test]
public function returns_null_when_no_api_key_present(): void
{
$authenticator = $this->buildAuthenticator([]);
$request = TestHelper::createMockRequest();
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function authenticates_via_header(): void
{
$user = TestHelper::createMockUser('alice');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'alice',
'hash' => hash('sha256', self::RAW_KEY),
'active' => true,
'expires' => null,
],
], ['alice' => $user]);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => self::RAW_KEY],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('alice', $result->username);
}
#[Test]
public function authenticates_via_query_param(): void
{
$user = TestHelper::createMockUser('bob');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'bob',
'hash' => hash('sha256', self::RAW_KEY),
'active' => true,
'expires' => null,
],
], ['bob' => $user]);
$request = TestHelper::createMockRequest(
queryParams: ['api_key' => self::RAW_KEY],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('bob', $result->username);
}
#[Test]
public function returns_null_for_invalid_key(): void
{
$user = TestHelper::createMockUser('carol');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'carol',
'hash' => hash('sha256', 'some_other_key'),
'active' => true,
],
], ['carol' => $user]);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => 'grav_wrong_key_value'],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function returns_null_for_inactive_key(): void
{
$user = TestHelper::createMockUser('dave');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'dave',
'hash' => hash('sha256', self::RAW_KEY),
'active' => false,
],
], ['dave' => $user]);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => self::RAW_KEY],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function returns_null_for_expired_key(): void
{
$user = TestHelper::createMockUser('eve');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'eve',
'hash' => hash('sha256', self::RAW_KEY),
'active' => true,
'expires' => time() - 3600, // expired an hour ago
],
], ['eve' => $user]);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => self::RAW_KEY],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function returns_null_when_account_does_not_exist(): void
{
// Key matches, but no account exists for its username.
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'ghost',
'hash' => hash('sha256', self::RAW_KEY),
'active' => true,
],
], []);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => self::RAW_KEY],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function header_takes_precedence_over_query_param(): void
{
$headerKey = 'grav_header_key_value_123456789';
$queryKey = 'grav_query_key_value_987654321';
$user = TestHelper::createMockUser('frank');
$authenticator = $this->buildAuthenticator([
'key1' => [
'id' => 'key1',
'username' => 'frank',
'hash' => hash('sha256', $headerKey),
'active' => true,
],
], ['frank' => $user]);
$request = TestHelper::createMockRequest(
headers: ['X-API-Key' => $headerKey],
queryParams: ['api_key' => $queryKey],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('frank', $result->username);
}
}
@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Auth;
use Grav\Common\Yaml;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Unit tests for ApiKeyManager.
*
* Keys are stored centrally in user/data/api-keys.yaml (keyed by id, each
* entry carrying its owning `username`), so the tests seed and inspect that
* central store via a temp-dir `user://data` locator rather than the user
* object itself.
*/
#[CoversClass(ApiKeyManager::class)]
class ApiKeyManagerTest extends TestCase
{
private ApiKeyManager $manager;
private string $dataDir;
protected function setUp(): void
{
$this->dataDir = sys_get_temp_dir() . '/grav_api_keys_test_' . uniqid();
@mkdir($this->dataDir, 0775, true);
$dataDir = $this->dataDir;
$locator = new class ($dataDir) {
public function __construct(private string $dir) {}
public function findResource(string $uri, bool $absolute = true, bool $first = false): string
{
return $this->dir;
}
};
TestHelper::createMockGrav(['locator' => $locator]);
$this->resetKeysCache();
$this->manager = new ApiKeyManager();
}
protected function tearDown(): void
{
$this->resetKeysCache();
$this->rmrf($this->dataDir);
}
/** Clear the ApiKeyManager static cache so each test starts clean. */
private function resetKeysCache(): void
{
(new \ReflectionProperty(ApiKeyManager::class, 'keysCache'))->setValue(null, null);
}
/** Seed the central api-keys.yaml store with the given entries. */
private function seedKeys(array $keys): void
{
file_put_contents($this->dataDir . '/api-keys.yaml', Yaml::dump($keys));
$this->resetKeysCache();
}
private function rmrf(string $dir): void
{
if (!is_dir($dir)) {
return;
}
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
is_dir($path) ? $this->rmrf($path) : unlink($path);
}
rmdir($dir);
}
#[Test]
public function generate_key_returns_key_and_id(): void
{
$user = TestHelper::createMockUser('alice');
$result = $this->manager->generateKey($user);
self::assertArrayHasKey('key', $result);
self::assertArrayHasKey('id', $result);
self::assertNotEmpty($result['key']);
self::assertNotEmpty($result['id']);
}
#[Test]
public function generated_key_starts_with_grav_prefix(): void
{
$user = TestHelper::createMockUser('bob');
$result = $this->manager->generateKey($user);
self::assertStringStartsWith('grav_', $result['key']);
}
#[Test]
public function generated_key_is_stored_centrally(): void
{
$user = TestHelper::createMockUser('carol');
$result = $this->manager->generateKey($user, 'My Key', ['read', 'write']);
$keys = $this->manager->loadKeys();
self::assertArrayHasKey($result['id'], $keys);
$stored = $keys[$result['id']];
self::assertSame($result['id'], $stored['id']);
self::assertSame('carol', $stored['username']);
self::assertSame('My Key', $stored['name']);
// The raw key verifies against the stored hash (bcrypt, not reversible).
self::assertTrue(ApiKeyManager::verifyKey($result['key'], $stored['hash']));
self::assertSame(['read', 'write'], $stored['scopes']);
self::assertTrue($stored['active']);
self::assertNotNull($stored['created']);
self::assertNull($stored['last_used']);
self::assertNull($stored['expires']);
}
#[Test]
public function generated_key_stores_prefix(): void
{
$user = TestHelper::createMockUser('dave');
$result = $this->manager->generateKey($user);
$stored = $this->manager->loadKeys()[$result['id']];
// Prefix should be first 12 chars of the raw key followed by '...'
$expectedPrefix = substr($result['key'], 0, 12) . '...';
self::assertSame($expectedPrefix, $stored['prefix']);
}
#[Test]
public function default_key_name_is_api_key(): void
{
$user = TestHelper::createMockUser('eve');
$result = $this->manager->generateKey($user);
self::assertSame('API Key', $this->manager->loadKeys()[$result['id']]['name']);
}
#[Test]
public function list_keys_excludes_hashes(): void
{
$this->seedKeys([
'k1' => [
'id' => 'k1',
'username' => 'frank',
'name' => 'Production',
'hash' => 'abc123secrethash',
'prefix' => 'grav_abc123...',
'scopes' => ['read'],
'active' => true,
'created' => 1700000000,
'last_used' => null,
'expires' => null,
],
'k2' => [
'id' => 'k2',
'username' => 'frank',
'name' => 'Staging',
'hash' => 'def456secrethash',
'prefix' => 'grav_def456...',
'scopes' => [],
'active' => false,
'created' => 1700001000,
'last_used' => 1700002000,
'expires' => 1700100000,
],
]);
$user = TestHelper::createMockUser('frank');
$list = $this->manager->listKeys($user);
self::assertCount(2, $list);
// Verify no hash field is present in the output
foreach ($list as $item) {
self::assertArrayNotHasKey('hash', $item);
self::assertArrayHasKey('id', $item);
self::assertArrayHasKey('name', $item);
self::assertArrayHasKey('prefix', $item);
self::assertArrayHasKey('scopes', $item);
self::assertArrayHasKey('active', $item);
self::assertArrayHasKey('created', $item);
self::assertArrayHasKey('last_used', $item);
self::assertArrayHasKey('expires', $item);
}
self::assertSame('Production', $list[0]['name']);
self::assertSame('Staging', $list[1]['name']);
}
#[Test]
public function list_keys_only_returns_keys_for_the_given_user(): void
{
$this->seedKeys([
'k1' => ['id' => 'k1', 'username' => 'frank', 'name' => 'Mine', 'hash' => 'h'],
'k2' => ['id' => 'k2', 'username' => 'someone_else', 'name' => 'Theirs', 'hash' => 'h'],
]);
$list = $this->manager->listKeys(TestHelper::createMockUser('frank'));
self::assertCount(1, $list);
self::assertSame('Mine', $list[0]['name']);
}
#[Test]
public function list_keys_skips_non_array_entries(): void
{
$this->seedKeys([
'k1' => [
'id' => 'k1',
'username' => 'grace',
'name' => 'Valid Key',
'hash' => 'somehash',
'prefix' => 'grav_aaa...',
'scopes' => [],
'active' => true,
'created' => 1700000000,
'last_used' => null,
'expires' => null,
],
'corrupted' => 'not_an_array',
]);
$list = $this->manager->listKeys(TestHelper::createMockUser('grace'));
self::assertCount(1, $list);
self::assertSame('Valid Key', $list[0]['name']);
}
#[Test]
public function revoke_key_removes_it(): void
{
$this->seedKeys([
'k1' => ['id' => 'k1', 'username' => 'heidi', 'name' => 'To be revoked', 'hash' => 'somehash'],
'k2' => ['id' => 'k2', 'username' => 'heidi', 'name' => 'Keeper', 'hash' => 'otherhash'],
]);
$result = $this->manager->revokeKey(TestHelper::createMockUser('heidi'), 'k1');
self::assertTrue($result);
$keys = $this->manager->loadKeys();
self::assertArrayNotHasKey('k1', $keys);
self::assertArrayHasKey('k2', $keys);
}
#[Test]
public function revoke_nonexistent_key_returns_false(): void
{
$this->seedKeys([
'k1' => ['id' => 'k1', 'username' => 'ivan', 'name' => 'Existing', 'hash' => 'h'],
]);
$result = $this->manager->revokeKey(TestHelper::createMockUser('ivan'), 'nonexistent');
self::assertFalse($result);
// The existing key should remain untouched
self::assertArrayHasKey('k1', $this->manager->loadKeys());
}
#[Test]
public function revoke_does_not_remove_another_users_key(): void
{
$this->seedKeys([
'k1' => ['id' => 'k1', 'username' => 'owner', 'name' => 'Owned', 'hash' => 'h'],
]);
// A different user attempting to revoke a key they don't own fails.
$result = $this->manager->revokeKey(TestHelper::createMockUser('attacker'), 'k1');
self::assertFalse($result);
self::assertArrayHasKey('k1', $this->manager->loadKeys());
}
#[Test]
public function multiple_keys_can_be_generated_for_same_user(): void
{
$user = TestHelper::createMockUser('judy');
$first = $this->manager->generateKey($user, 'First Key');
$second = $this->manager->generateKey($user, 'Second Key');
self::assertNotSame($first['key'], $second['key']);
self::assertNotSame($first['id'], $second['id']);
$keys = $this->manager->loadKeys();
self::assertCount(2, $keys);
self::assertSame('judy', $keys[$first['id']]['username']);
self::assertSame('judy', $keys[$second['id']]['username']);
}
#[Test]
public function touch_key_updates_last_used(): void
{
$this->seedKeys([
'k1' => [
'id' => 'k1',
'username' => 'kate',
'name' => 'Touch Test',
'hash' => 'somehash',
'last_used' => null,
],
]);
$this->manager->touchKey('k1');
$keys = $this->manager->loadKeys();
self::assertNotNull($keys['k1']['last_used']);
self::assertEqualsWithDelta(time(), $keys['k1']['last_used'], 2);
}
#[Test]
public function find_key_matches_raw_key_against_central_store(): void
{
$rawKey = 'grav_find_me_raw_value_0123456789';
$this->seedKeys([
'k1' => [
'id' => 'k1',
'username' => 'leo',
'name' => 'Findable',
'hash' => hash('sha256', $rawKey),
],
]);
$match = $this->manager->findKey($rawKey);
self::assertNotNull($match);
self::assertSame('k1', $match['key_id']);
self::assertSame('leo', $match['username']);
self::assertNull($this->manager->findKey('grav_not_a_real_key'));
}
}
@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Tests\Unit\Auth;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Tests\Unit\TestHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Tests for the JwtAuthenticator.
*
* We subclass JwtAuthenticator to override getSecret() and getRevokedTokensFile()
* so the tests run without a full Grav file system.
*/
#[CoversClass(JwtAuthenticator::class)]
class JwtAuthenticatorTest extends TestCase
{
private const SECRET = 'test-jwt-secret-key-at-least-32-chars-long';
private const ALGORITHM = 'HS256';
private string $tempDir;
protected function setUp(): void
{
$this->tempDir = sys_get_temp_dir() . '/grav_api_jwt_test_' . uniqid();
@mkdir($this->tempDir, 0775, true);
}
protected function tearDown(): void
{
$files = glob($this->tempDir . '/*');
if ($files) {
array_map('unlink', $files);
}
@rmdir($this->tempDir);
}
#[Test]
public function returns_null_when_no_bearer_token(): void
{
$authenticator = $this->buildAuthenticator([]);
$request = TestHelper::createMockRequest();
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function returns_null_with_non_bearer_authorization(): void
{
$authenticator = $this->buildAuthenticator([]);
$request = TestHelper::createMockRequest(
headers: ['Authorization' => 'Basic dXNlcjpwYXNz'],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function authenticates_valid_access_token(): void
{
$user = TestHelper::createMockUser('alice');
$authenticator = $this->buildAuthenticator(['alice' => $user]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'alice',
'iat' => time(),
'exp' => time() + 3600,
'type' => 'access',
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['Authorization' => 'Bearer ' . $token],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('alice', $result->username);
}
#[Test]
public function authenticates_via_x_api_token_bare_jwt(): void
{
$user = TestHelper::createMockUser('alice');
$authenticator = $this->buildAuthenticator(['alice' => $user]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'alice',
'iat' => time(),
'exp' => time() + 3600,
'type' => 'access',
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['X-API-Token' => $token],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('alice', $result->username);
}
#[Test]
public function authenticates_via_x_api_token_with_bearer_prefix(): void
{
$user = TestHelper::createMockUser('alice');
$authenticator = $this->buildAuthenticator(['alice' => $user]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'alice',
'iat' => time(),
'exp' => time() + 3600,
'type' => 'access',
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['X-API-Token' => 'Bearer ' . $token],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('alice', $result->username);
}
#[Test]
public function x_api_token_takes_precedence_over_authorization(): void
{
$alice = TestHelper::createMockUser('alice');
$bob = TestHelper::createMockUser('bob');
$authenticator = $this->buildAuthenticator(['alice' => $alice, 'bob' => $bob]);
$aliceToken = JWT::encode([
'iss' => 'grav-api', 'sub' => 'alice', 'iat' => time(),
'exp' => time() + 3600, 'type' => 'access',
], self::SECRET, self::ALGORITHM);
$bobToken = JWT::encode([
'iss' => 'grav-api', 'sub' => 'bob', 'iat' => time(),
'exp' => time() + 3600, 'type' => 'access',
], self::SECRET, self::ALGORITHM);
// X-API-Token carries Alice's JWT; Authorization carries Bob's.
// Custom header wins (FPM-stripping hosts may drop Authorization
// silently, so we want the survivable channel to be authoritative).
$request = TestHelper::createMockRequest(
headers: [
'X-API-Token' => $aliceToken,
'Authorization' => 'Bearer ' . $bobToken,
],
);
$result = $authenticator->authenticate($request);
self::assertNotNull($result);
self::assertSame('alice', $result->username);
}
#[Test]
public function rejects_expired_token(): void
{
$user = TestHelper::createMockUser('bob');
$authenticator = $this->buildAuthenticator(['bob' => $user]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'bob',
'iat' => time() - 7200,
'exp' => time() - 3600,
'type' => 'access',
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['Authorization' => 'Bearer ' . $token],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function rejects_refresh_token_as_access(): void
{
$user = TestHelper::createMockUser('carol');
$authenticator = $this->buildAuthenticator(['carol' => $user]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'carol',
'iat' => time(),
'exp' => time() + 604800,
'type' => 'refresh',
'jti' => bin2hex(random_bytes(16)),
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['Authorization' => 'Bearer ' . $token],
);
self::assertNull($authenticator->authenticate($request), 'Refresh tokens must not authenticate as access tokens');
}
#[Test]
public function rejects_nonexistent_user(): void
{
$authenticator = $this->buildAuthenticator([]);
$token = JWT::encode([
'iss' => 'grav-api',
'sub' => 'ghost',
'iat' => time(),
'exp' => time() + 3600,
'type' => 'access',
], self::SECRET, self::ALGORITHM);
$request = TestHelper::createMockRequest(
headers: ['Authorization' => 'Bearer ' . $token],
);
self::assertNull($authenticator->authenticate($request));
}
#[Test]
public function generate_access_token_is_valid(): void
{
$user = TestHelper::createMockUser('dave');
$authenticator = $this->buildAuthenticator(['dave' => $user]);
$token = $authenticator->generateAccessToken($user);
self::assertNotEmpty($token);
$decoded = JWT::decode($token, new Key(self::SECRET, self::ALGORITHM));
self::assertSame('grav-api', $decoded->iss);
self::assertSame('dave', $decoded->sub);
self::assertSame('access', $decoded->type);
self::assertGreaterThan(time(), $decoded->exp);
}
#[Test]
public function generate_refresh_token_is_valid(): void
{
$user = TestHelper::createMockUser('eve');
$authenticator = $this->buildAuthenticator(['eve' => $user]);
$token = $authenticator->generateRefreshToken($user);
self::assertNotEmpty($token);
$decoded = JWT::decode($token, new Key(self::SECRET, self::ALGORITHM));
self::assertSame('grav-api', $decoded->iss);
self::assertSame('eve', $decoded->sub);
self::assertSame('refresh', $decoded->type);
self::assertNotEmpty($decoded->jti);
self::assertGreaterThan(time(), $decoded->exp);
}
#[Test]
public function refresh_token_validation(): void
{
$user = TestHelper::createMockUser('frank');
$authenticator = $this->buildAuthenticator(['frank' => $user]);
$refreshToken = $authenticator->generateRefreshToken($user);
$result = $authenticator->validateRefreshToken($refreshToken);
self::assertNotNull($result);
self::assertSame('frank', $result->username);
}
#[Test]
public function refresh_token_validation_rejects_access_token(): void
{
$user = TestHelper::createMockUser('grace');
$authenticator = $this->buildAuthenticator(['grace' => $user]);
$accessToken = $authenticator->generateAccessToken($user);
$result = $authenticator->validateRefreshToken($accessToken);
self::assertNull($result, 'Access tokens must not be accepted as refresh tokens');
}
#[Test]
public function revoke_token(): void
{
$user = TestHelper::createMockUser('heidi');
$authenticator = $this->buildAuthenticator(['heidi' => $user]);
$refreshToken = $authenticator->generateRefreshToken($user);
// Token should be valid before revocation
self::assertNotNull($authenticator->validateRefreshToken($refreshToken));
// Revoke it
$revoked = $authenticator->revokeToken($refreshToken);
self::assertTrue($revoked);
// Token should be rejected after revocation
self::assertNull($authenticator->validateRefreshToken($refreshToken));
}
#[Test]
public function revoke_access_token_returns_false(): void
{
$user = TestHelper::createMockUser('ivan');
$authenticator = $this->buildAuthenticator(['ivan' => $user]);
$accessToken = $authenticator->generateAccessToken($user);
// Access tokens have no jti, so revocation should return false
$result = $authenticator->revokeToken($accessToken);
self::assertFalse($result);
}
/**
* Build a testable JwtAuthenticator subclass that doesn't depend on the Grav locator.
*/
private function buildAuthenticator(array $users): JwtAuthenticator
{
$accounts = TestHelper::createMockAccounts($users);
$grav = TestHelper::createMockGrav(['accounts' => $accounts]);
$config = TestHelper::createMockConfig([
'plugins' => ['api' => ['auth' => [
'jwt_secret' => self::SECRET,
'jwt_algorithm' => self::ALGORITHM,
'jwt_expiry' => 3600,
'jwt_refresh_expiry' => 604800,
]]],
]);
$tempDir = $this->tempDir;
return new class ($grav, $config, $tempDir) extends JwtAuthenticator {
public function __construct(
Grav $grav,
Config $config,
private readonly string $dir,
) {
parent::__construct($grav, $config);
}
protected function getSecret(): string
{
return $this->config->get('plugins.api.auth.jwt_secret');
}
protected function getRevokedTokensFile(): string
{
return $this->dir . '/revoked_tokens.json';
}
};
}
}