CaiWuXiTong/tests/Feature/Auth/DingTalkLoginTest.php

184 lines
6.7 KiB
PHP

<?php
namespace Tests\Feature\Auth;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\DingTalkOAuthState;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\Http;
use Tests\Feature\FeatureTestCase;
class DingTalkLoginTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'dingtalk.enabled' => true,
'dingtalk.client_id' => 'test-client-id',
'dingtalk.client_secret' => 'test-client-secret',
'dingtalk.redirect_uri' => 'http://218.93.190.75:8442/auth/dingtalk/callback',
'dingtalk.authorize_url' => 'https://login.dingtalk.test/oauth2/auth',
'dingtalk.token_url' => 'https://api.dingtalk.test/v1.0/oauth2/userAccessToken',
'dingtalk.user_url' => 'https://api.dingtalk.test/v1.0/contact/users/me',
]);
}
public function test_login_page_shows_dingtalk_entry_when_configured(): void
{
$this->get(route('login'))
->assertOk()
->assertSeeText(trans('auth.dingtalk.login'));
}
public function test_redirect_creates_hashed_state_and_uses_registered_http_callback(): void
{
$response = $this->get(route('login.dingtalk'))->assertRedirect();
parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
$this->assertSame('test-client-id', $query['client_id']);
$this->assertSame('openid', $query['scope']);
$this->assertSame(config('dingtalk.redirect_uri'), $query['redirect_uri']);
$this->assertDatabaseHas('dingtalk_oauth_states', [
'state_hash' => hash('sha256', $query['state']),
'consumed_at' => null,
]);
$this->assertDatabaseMissing('dingtalk_oauth_states', ['state_hash' => $query['state']]);
}
public function test_bound_identity_logs_in_without_storing_access_token(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-bound', 'open-bound'));
DingTalkIdentity::query()->create([
'user_id' => $this->user->id,
'union_id' => 'union-bound',
]);
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', [
'authCode' => 'authorization-code',
'state' => $state,
]))->assertRedirect();
$this->assertAuthenticatedAs($this->user);
$this->assertDatabaseHas('dingtalk_identities', [
'user_id' => $this->user->id,
'union_id' => 'union-bound',
'open_id' => 'open-bound',
]);
$this->assertFalse(
collect(DingTalkOAuthState::query()->first()->getAttributes())
->contains(fn ($value) => is_string($value) && str_contains($value, 'access-token'))
);
Http::assertSent(function (ClientRequest $request): bool {
if ($request->url() === 'https://api.dingtalk.test/v1.0/oauth2/userAccessToken') {
return $request->method() === 'POST'
&& $request['clientId'] === 'test-client-id'
&& $request['clientSecret'] === 'test-client-secret'
&& $request['code'] === 'authorization-code'
&& $request['grantType'] === 'authorization_code';
}
if ($request->url() === 'https://api.dingtalk.test/v1.0/contact/users/me') {
return $request->hasHeader('x-acs-dingtalk-access-token', 'access-token-not-persisted');
}
return false;
});
Http::assertSentCount(2);
}
public function test_first_authorization_binds_only_after_existing_password_login(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-new', 'open-new'));
$password = 'KnownPassword123!';
$this->user->password = $password;
$this->user->save();
$state = $this->startAuthorization();
$callback = $this->get(route('login.dingtalk.callback', [
'code' => 'authorization-code',
'state' => $state,
]))->assertRedirect();
parse_str((string) parse_url($callback->headers->get('Location'), PHP_URL_QUERY), $query);
$bindingToken = $query['dingtalk_binding'];
$this->assertGuest();
$this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-new']);
$this->assertDatabaseHas('dingtalk_oauth_states', [
'binding_token_hash' => hash('sha256', $bindingToken),
'union_id' => 'union-new',
]);
$this->post(route('login.store'), [
'email' => $this->user->email,
'password' => $password,
'dingtalk_binding' => $bindingToken,
])->assertOk()->assertJson(['success' => true]);
$this->assertAuthenticatedAs($this->user);
$this->assertDatabaseHas('dingtalk_identities', [
'user_id' => $this->user->id,
'union_id' => 'union-new',
'open_id' => 'open-new',
]);
$this->assertDatabaseMissing('dingtalk_oauth_states', [
'binding_token_hash' => hash('sha256', $bindingToken),
]);
}
public function test_expired_or_replayed_state_never_calls_dingtalk(): void
{
Http::preventStrayRequests();
$rawState = 'expired-state';
DingTalkOAuthState::query()->create([
'state_hash' => hash('sha256', $rawState),
'expires_at' => now()->subMinute(),
]);
$this->get(route('login.dingtalk.callback', [
'code' => 'authorization-code',
'state' => $rawState,
]))->assertRedirect(route('login'));
Http::assertNothingSent();
$this->assertGuest();
}
protected function startAuthorization(): string
{
$response = $this->get(route('login.dingtalk'))->assertRedirect();
parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
return $query['state'];
}
protected function dingtalkResponses(string $unionId, string $openId): callable
{
return function (ClientRequest $request) use ($unionId, $openId) {
return match ($request->url()) {
'https://api.dingtalk.test/v1.0/oauth2/userAccessToken' => Http::response([
'accessToken' => 'access-token-not-persisted',
'expireIn' => 7200,
]),
'https://api.dingtalk.test/v1.0/contact/users/me' => Http::response([
'unionId' => $unionId,
'openId' => $openId,
'nick' => '测试用户',
]),
default => Http::response([], 404),
};
};
}
}