CaiWuXiTong/tests/Feature/Auth/DingTalkLoginTest.php

318 lines
13 KiB
PHP

<?php
namespace Tests\Feature\Auth;
use App\Models\Auth\DingTalkBindingRequest;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\DingTalkOAuthState;
use App\Models\Auth\User;
use App\Models\Common\Contact;
use Illuminate\Http\Client\Request as ClientRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\Feature\FeatureTestCase;
class DingTalkLoginTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'app.url' => 'https://caiwu.jiansuntuzhi.cn:8442',
'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',
'dingtalk.app_token_url' => 'https://oapi.dingtalk.test/gettoken',
'dingtalk.user_by_union_id_url' => 'https://oapi.dingtalk.test/topapi/user/getbyunionid',
'dingtalk.user_detail_url' => 'https://oapi.dingtalk.test/topapi/v2/user/get',
]);
}
public function test_login_page_shows_dingtalk_entry_without_password_binding_fields(): void
{
$this->get(route('login'))
->assertOk()
->assertSeeText(trans('auth.dingtalk.login'))
->assertDontSee('name="dingtalk_binding"', false)
->assertDontSeeText('首次使用钉钉登录,请输入现有财务系统账号和密码完成绑定');
}
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_active_enterprise_identity_logs_in_without_storing_access_tokens(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-bound', 'open-bound', email: null));
DingTalkIdentity::query()->create([
'user_id' => $this->user->id,
'union_id' => 'union-bound',
'dingtalk_user_id' => 'ding-user-1',
]);
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', [
'authCode' => 'authorization-code',
'state' => $state,
]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442');
$this->assertAuthenticatedAs($this->user);
$this->assertDatabaseHas('dingtalk_auth_audits', [
'event' => 'login_success',
'outcome' => 'success',
'user_id' => $this->user->id,
]);
$this->assertFalse(
collect(DingTalkOAuthState::query()->first()->getAttributes())
->contains(fn ($value) => is_string($value) && str_contains($value, 'access-token'))
);
Http::assertSentCount(5);
}
public function test_unique_verified_email_auto_binds_existing_enabled_account(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-auto', 'open-auto', email: 'finance@example.test'));
$this->user->update(['email' => 'finance@example.test']);
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', [
'code' => 'authorization-code',
'state' => $state,
]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442');
$this->assertAuthenticatedAs($this->user);
$this->assertDatabaseHas('dingtalk_identities', [
'user_id' => $this->user->id,
'union_id' => 'union-auto',
'dingtalk_user_id' => 'ding-user-1',
]);
$this->assertDatabaseHas('dingtalk_binding_requests', [
'union_id' => 'union-auto',
'status' => 'approved',
'matched_user_id' => $this->user->id,
]);
$this->assertDatabaseHas('dingtalk_auth_audits', [
'event' => 'binding_auto_approved',
'outcome' => 'success',
'user_id' => $this->user->id,
]);
}
public function test_unique_verified_mobile_auto_binds_existing_enabled_account(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-mobile', 'open-mobile', email: null, mobile: '+86 138-0013-8000'));
Contact::query()->create([
'company_id' => $this->company->id,
'type' => Contact::EMPLOYEE_TYPE,
'name' => $this->user->name,
'email' => $this->user->email,
'user_id' => $this->user->id,
'phone' => '13800138000',
'currency_code' => default_currency(),
'enabled' => true,
'created_from' => 'test',
'created_by' => $this->user->id,
]);
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', [
'code' => 'authorization-code',
'state' => $state,
]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442');
$this->assertAuthenticatedAs($this->user);
$this->assertDatabaseHas('dingtalk_identities', [
'user_id' => $this->user->id,
'union_id' => 'union-mobile',
]);
}
public function test_unmatched_enterprise_user_creates_pending_request_without_login_or_raw_contact_data(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses(
'union-pending',
'open-pending',
email: 'outsider@example.test',
mobile: '13800138000'
));
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', [
'code' => 'authorization-code',
'state' => $state,
]))->assertRedirect('https://caiwu.jiansuntuzhi.cn:8442/auth/login')
->assertSessionHas('dingtalk_error', trans('auth.dingtalk.denied'));
$this->assertGuest();
$this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-pending']);
$this->assertDatabaseHas('dingtalk_binding_requests', [
'union_id' => 'union-pending',
'status' => 'pending',
'email_hint' => 'o***@example.test',
'mobile_hint' => '*******8000',
]);
$row = (array) DB::table('dingtalk_binding_requests')->where('union_id', 'union-pending')->first();
$this->assertStringNotContainsString('outsider@example.test', json_encode($row));
$this->assertStringNotContainsString('13800138000', json_encode($row));
}
public function test_inactive_enterprise_user_is_denied_without_creating_an_account(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-inactive', 'open-inactive', active: false));
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', [
'code' => 'authorization-code',
'state' => $state,
]))->assertRedirect('https://caiwu.jiansuntuzhi.cn:8442/auth/login');
$this->assertGuest();
$this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-inactive']);
$this->assertDatabaseMissing('dingtalk_binding_requests', ['union_id' => 'union-inactive']);
$this->assertDatabaseHas('dingtalk_auth_audits', [
'event' => 'login_denied',
'outcome' => 'denied',
]);
}
public function test_external_dingtalk_user_is_denied_when_enterprise_application_cannot_resolve_it(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-external', 'open-external', enterpriseMember: false));
$userCount = User::query()->count();
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', [
'code' => 'authorization-code',
'state' => $state,
]))->assertRedirect('https://caiwu.jiansuntuzhi.cn:8442/auth/login');
$this->assertGuest();
$this->assertSame($userCount, User::query()->count());
$this->assertDatabaseMissing('dingtalk_binding_requests', ['union_id' => 'union-external']);
$this->assertDatabaseHas('dingtalk_auth_audits', [
'event' => 'login_denied',
'outcome' => 'denied',
]);
}
public function test_rejected_binding_request_cannot_be_automatically_reactivated(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-rejected', 'open-rejected', email: 'finance@example.test'));
$this->user->update(['email' => 'finance@example.test']);
DingTalkBindingRequest::query()->create([
'union_id' => 'union-rejected',
'status' => DingTalkBindingRequest::REJECTED,
'match_reason' => 'administrator_rejected',
'last_attempted_at' => now()->subMinute(),
]);
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', [
'code' => 'authorization-code',
'state' => $state,
]))->assertRedirect('https://caiwu.jiansuntuzhi.cn:8442/auth/login');
$this->assertGuest();
$this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-rejected']);
$this->assertDatabaseHas('dingtalk_binding_requests', [
'union_id' => 'union-rejected',
'status' => DingTalkBindingRequest::REJECTED,
]);
}
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('https://caiwu.jiansuntuzhi.cn:8442/auth/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,
?string $email = 'finance@example.test',
?string $mobile = null,
bool $active = true,
bool $enterpriseMember = true
): callable {
return function (ClientRequest $request) use ($unionId, $openId, $email, $mobile, $active, $enterpriseMember) {
$url = $request->url();
return match (true) {
$url === 'https://api.dingtalk.test/v1.0/oauth2/userAccessToken' => Http::response([
'accessToken' => 'access-token-not-persisted',
'expireIn' => 7200,
]),
$url === 'https://api.dingtalk.test/v1.0/contact/users/me' => Http::response([
'unionId' => $unionId,
'openId' => $openId,
'nick' => '企业员工',
]),
Str::startsWith($url, 'https://oapi.dingtalk.test/gettoken?') => Http::response([
'errcode' => 0,
'access_token' => 'app-access-token-not-persisted',
]),
Str::startsWith($url, 'https://oapi.dingtalk.test/topapi/user/getbyunionid?') => Http::response([
'errcode' => $enterpriseMember ? 0 : 60121,
'result' => $enterpriseMember ? ['userid' => 'ding-user-1'] : null,
]),
Str::startsWith($url, 'https://oapi.dingtalk.test/topapi/v2/user/get?') => Http::response([
'errcode' => 0,
'result' => [
'userid' => 'ding-user-1',
'name' => '企业员工',
'email' => $email,
'mobile' => $mobile,
'active' => $active,
],
]),
default => Http::response([], 404),
};
};
}
}