2026-03-09 10:15:43 +08:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace HyperfTest\Cases\Unit\Model;
|
|
|
|
|
|
|
|
|
|
use App\Model\ApiKey;
|
|
|
|
|
use App\Model\User;
|
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @internal
|
|
|
|
|
* @coversNothing
|
|
|
|
|
*/
|
|
|
|
|
class ApiKeyTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
protected function runInCoroutine(callable $callback): void
|
|
|
|
|
{
|
2026-03-09 13:36:24 +08:00
|
|
|
if (\Swoole\Coroutine::getCid() > 0) {
|
|
|
|
|
$callback();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 10:15:43 +08:00
|
|
|
$exception = null;
|
|
|
|
|
\Swoole\Coroutine\run(static function () use ($callback, &$exception): void {
|
|
|
|
|
try {
|
|
|
|
|
$callback();
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$exception = $e;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if ($exception) {
|
|
|
|
|
throw $exception;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function createTestUser(): User
|
|
|
|
|
{
|
|
|
|
|
$suffix = bin2hex(random_bytes(4));
|
|
|
|
|
|
|
|
|
|
return User::query()->create([
|
|
|
|
|
'username' => 'apikey_test_' . $suffix,
|
|
|
|
|
'password' => 'Pass_' . $suffix,
|
|
|
|
|
'email' => 'apikey_test_' . $suffix . '@example.com',
|
|
|
|
|
'status' => 1,
|
|
|
|
|
'api_key_enabled' => true,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:16:43 +08:00
|
|
|
public function test_generate_returns_plain_key_with_user_id_prefix(): void
|
2026-03-09 10:15:43 +08:00
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
$user = $this->createTestUser();
|
|
|
|
|
$result = ApiKey::generate($user->id, 'Test Key');
|
|
|
|
|
|
|
|
|
|
$this->assertArrayHasKey('plain_key', $result);
|
|
|
|
|
$this->assertArrayHasKey('api_key', $result);
|
|
|
|
|
$this->assertInstanceOf(ApiKey::class, $result['api_key']);
|
2026-04-16 13:16:43 +08:00
|
|
|
|
|
|
|
|
// 格式为 {user_id}#{64hex}
|
|
|
|
|
$this->assertStringContainsString('#', $result['plain_key']);
|
|
|
|
|
[$prefix, $token] = explode('#', $result['plain_key'], 2);
|
|
|
|
|
$this->assertSame((string) $user->id, $prefix);
|
|
|
|
|
$this->assertSame(64, strlen($token));
|
|
|
|
|
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $token);
|
2026-03-09 10:15:43 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:16:43 +08:00
|
|
|
public function test_generate_stores_token_only_hash(): void
|
2026-03-09 10:15:43 +08:00
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
$user = $this->createTestUser();
|
|
|
|
|
$result = ApiKey::generate($user->id, 'Hash Test');
|
|
|
|
|
|
2026-04-16 13:16:43 +08:00
|
|
|
// key_hash 是 token 部分的 SHA-256,不含 user_id 前缀
|
|
|
|
|
$token = explode('#', $result['plain_key'], 2)[1];
|
|
|
|
|
$expected_hash = hash('sha256', $token);
|
2026-03-09 10:15:43 +08:00
|
|
|
$this->assertSame($expected_hash, $result['api_key']->key_hash);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:16:43 +08:00
|
|
|
public function test_generate_key_prefix_from_token_part(): void
|
2026-03-09 10:15:43 +08:00
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
$user = $this->createTestUser();
|
|
|
|
|
$result = ApiKey::generate($user->id, 'Prefix Test');
|
|
|
|
|
|
2026-04-16 13:16:43 +08:00
|
|
|
// key_prefix 取 token 前 8 位(不是 plain_key 前 8 位)
|
|
|
|
|
$token = explode('#', $result['plain_key'], 2)[1];
|
|
|
|
|
$expected_prefix = substr($token, 0, 8);
|
2026-03-09 10:15:43 +08:00
|
|
|
$this->assertSame($expected_prefix, $result['api_key']->key_prefix);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_find_by_plain_key_returns_valid_key(): void
|
|
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
$user = $this->createTestUser();
|
|
|
|
|
$result = ApiKey::generate($user->id, 'Find Test');
|
|
|
|
|
|
|
|
|
|
$found = ApiKey::findByPlainKey($result['plain_key']);
|
|
|
|
|
$this->assertNotNull($found);
|
|
|
|
|
$this->assertSame($result['api_key']->id, $found->id);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:16:43 +08:00
|
|
|
public function test_find_by_plain_key_new_format(): void
|
|
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
$user = $this->createTestUser();
|
|
|
|
|
$result = ApiKey::generate($user->id, 'New Format Test');
|
|
|
|
|
|
|
|
|
|
// 新格式 {user_id}#{token} 能正确查找
|
|
|
|
|
$found = ApiKey::findByPlainKey($result['plain_key']);
|
|
|
|
|
$this->assertNotNull($found);
|
|
|
|
|
$this->assertSame($result['api_key']->id, $found->id);
|
|
|
|
|
$this->assertSame($user->id, $found->user_id);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_find_by_plain_key_rejects_old_format(): void
|
|
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
// 旧格式(纯 hex,不含 #)直接返回 null
|
|
|
|
|
$found = ApiKey::findByPlainKey(bin2hex(random_bytes(32)));
|
|
|
|
|
$this->assertNull($found);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 10:15:43 +08:00
|
|
|
public function test_find_by_plain_key_returns_null_for_invalid_key(): void
|
|
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
2026-04-16 13:16:43 +08:00
|
|
|
$found = ApiKey::findByPlainKey('999#invalid_token_that_does_not_exist');
|
2026-03-09 10:15:43 +08:00
|
|
|
$this->assertNull($found);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 14:41:31 +08:00
|
|
|
public function test_find_by_plain_key_returns_disabled_key(): void
|
2026-03-09 10:15:43 +08:00
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
$user = $this->createTestUser();
|
|
|
|
|
$result = ApiKey::generate($user->id, 'Disabled Test');
|
|
|
|
|
|
|
|
|
|
$result['api_key']->enabled = false;
|
|
|
|
|
$result['api_key']->save();
|
|
|
|
|
|
|
|
|
|
$found = ApiKey::findByPlainKey($result['plain_key']);
|
2026-04-02 14:41:31 +08:00
|
|
|
$this->assertNotNull($found);
|
|
|
|
|
$this->assertFalse($found->enabled);
|
2026-03-09 10:15:43 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_find_by_plain_key_excludes_expired_key(): void
|
|
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
$user = $this->createTestUser();
|
|
|
|
|
$result = ApiKey::generate($user->id, 'Expired Test', \Carbon\Carbon::now()->subDay()->toDateTimeString());
|
|
|
|
|
|
|
|
|
|
$found = ApiKey::findByPlainKey($result['plain_key']);
|
|
|
|
|
$this->assertNull($found);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_key_hash_is_hidden_in_json(): void
|
|
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
$user = $this->createTestUser();
|
|
|
|
|
$result = ApiKey::generate($user->id, 'Hidden Test');
|
|
|
|
|
|
|
|
|
|
$json = $result['api_key']->toArray();
|
|
|
|
|
$this->assertArrayNotHasKey('key_hash', $json);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_is_valid_returns_true_for_active_key(): void
|
|
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
$user = $this->createTestUser();
|
|
|
|
|
$result = ApiKey::generate($user->id, 'Valid Test');
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['api_key']->isValid());
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_is_valid_returns_false_for_disabled_key(): void
|
|
|
|
|
{
|
|
|
|
|
$this->runInCoroutine(function (): void {
|
|
|
|
|
$user = $this->createTestUser();
|
|
|
|
|
$result = ApiKey::generate($user->id, 'Disabled Valid');
|
|
|
|
|
|
|
|
|
|
$result['api_key']->enabled = false;
|
|
|
|
|
$this->assertFalse($result['api_key']->isValid());
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|