CaiWuXiTong/tests/Feature/Auth/DingTalkLoginTest.php

516 lines
24 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 App\Services\Auth\FinanceRoster;
use App\Utilities\SidebarPermissions;
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' => 'https://caiwu.jiansuntuzhi.cn: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',
]);
app('url')->forceRootUrl(config('app.url'));
app('url')->forceScheme('https');
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andReturn([
'roster_id' => 'fixture-1', 'display_name' => '财务测试员工', 'department_ids' => ['22848291'],
])->byDefault();
}
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_domain_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_finance_staff_are_provisioned_without_claiming_an_existing_email_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');
$created = auth()->user();
$this->assertNotSame($this->user->id, $created->id);
$this->assertTrue($created->hasRole('user'));
$this->assertFalse($created->hasRole('super_admin'));
$this->assertNull(SidebarPermissions::allowedKeys($created));
$this->assertTrue(collect(SidebarPermissions::navigationFor($created))->contains('slug', 'store-management'));
$this->assertDatabaseHas('dingtalk_identities', [
'user_id' => $created->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' => $created->id,
]);
$this->assertDatabaseHas('dingtalk_auth_audits', [
'event' => 'account_auto_created',
'outcome' => 'success',
'user_id' => $created->id,
]);
$this->get(route('dashboard', ['company_id' => $this->company->id]))->assertRedirectContains('/workspace/');
}
public function test_shared_mobile_does_not_claim_an_existing_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->assertNotSame($this->user->id, auth()->id());
$this->assertDatabaseHas('dingtalk_identities', [
'user_id' => auth()->id(),
'union_id' => 'union-mobile',
]);
}
public function test_finance_staff_receive_api_profile_and_no_manual_registration_step(): 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,
]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442');
$this->assertAuthenticated();
$this->assertDatabaseHas('dingtalk_identities', ['union_id' => 'union-pending', 'user_id' => auth()->id()]);
$this->assertSame('outsider@example.test', auth()->user()->email);
$this->assertSame('企业员工', auth()->user()->name);
$this->assertDatabaseHas('contacts', ['user_id' => auth()->id(), 'phone' => '13800138000']);
$this->assertDatabaseHas('dingtalk_binding_requests', [
'union_id' => 'union-pending',
'status' => 'approved',
'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->withSession(['dingtalk_oauth_state_hash' => hash('sha256', $rawState)]);
$this->get(route('login.dingtalk.callback', [
'code' => 'authorization-code',
'state' => $rawState,
]))->assertRedirect('https://caiwu.jiansuntuzhi.cn:8442/auth/login');
Http::assertNothingSent();
$this->assertGuest();
}
public function test_callback_requires_the_browser_that_started_authorization(): void
{
Http::preventStrayRequests();
$state = $this->startAuthorization();
$this->app['session']->forget('dingtalk_oauth_state_hash');
$this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]))
->assertSessionHas('dingtalk_error', trans('auth.dingtalk.failed'));
Http::assertNothingSent();
$this->assertGuest();
$this->assertDatabaseHas('dingtalk_oauth_states', ['state_hash' => hash('sha256', $state), 'consumed_at' => null]);
}
public function test_consumed_state_cannot_be_used_again_with_matching_browser_state(): void
{
Http::preventStrayRequests();
$state = $this->startAuthorization();
DingTalkOAuthState::query()->update(['consumed_at' => now()]);
$this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]))
->assertSessionHas('dingtalk_error', trans('auth.dingtalk.failed'));
Http::assertNothingSent();
$this->assertGuest();
}
public function test_legacy_callback_returns_to_domain_before_consuming_state(): void
{
Http::preventStrayRequests();
$state = $this->startAuthorization();
$response = $this->get('http://218.93.190.75:8442/auth/dingtalk/callback?'.http_build_query([
'code' => 'fake-code', 'state' => $state, 'redirect' => 'https://untrusted.test',
]))->assertRedirect();
$this->assertStringStartsWith(config('app.url').'/auth/dingtalk/callback?', $response->headers->get('Location'));
$this->assertStringNotContainsString('untrusted', $response->headers->get('Location'));
$response->assertHeader('Referrer-Policy', 'no-referrer');
$this->assertDatabaseHas('dingtalk_oauth_states', ['state_hash' => hash('sha256', $state), 'consumed_at' => null]);
Http::assertNothingSent();
}
public function test_revoked_binding_invalidates_logged_in_session_on_next_request(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-session', 'open-session', email: null));
$identity = DingTalkIdentity::query()->create(['user_id' => $this->user->id, 'union_id' => 'union-session']);
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]))
->assertSessionHas('dingtalk_identity_id', $identity->id);
$identity->delete();
$this->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized();
$this->assertGuest();
$this->assertFalse(session()->has('dingtalk_identity_id'));
}
public function test_non_finance_staff_cannot_create_accounts_even_with_a_matching_email(): void
{
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andReturnNull();
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-outside', 'open-outside', email: $this->user->email));
$count = User::count();
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]))
->assertSessionHas('dingtalk_error', trans('auth.dingtalk.outside_roster'));
$this->assertGuest();
$this->assertSame($count, User::count());
}
public function test_roster_outage_does_not_create_or_authorize_an_account(): void
{
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andThrow(new \RuntimeException('fixture outage'));
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-outage', 'open-outage'));
$count = User::count();
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]))
->assertSessionHas('dingtalk_error', trans('auth.dingtalk.roster_unavailable'));
$this->assertGuest();
$this->assertSame($count, User::count());
}
public function test_repeat_authorization_keeps_the_same_account_and_does_not_remember_login(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-repeat', 'open-repeat'));
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]))
->assertCookieExpired(auth()->guard()->getRecallerName());
$id = auth()->id();
$count = User::count();
auth()->logout();
session()->invalidate();
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', ['code' => 'another-code', 'state' => $state]));
$this->assertSame($id, auth()->id());
$this->assertSame($count, User::count());
$this->assertSame(1, DingTalkIdentity::where('union_id', 'union-repeat')->count());
}
public function test_roster_removal_invalidates_session_at_recheck(): void
{
$identity = DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-removed', 'dingtalk_user_id' => 'ding-removed']);
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->with('ding-removed')->andReturnNull();
$this->actingAs($this->user)->withSession([
'dingtalk_identity_id' => $identity->id,
'dingtalk_roster_checked_at' => now()->subMinutes(6)->timestamp,
])->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized();
$this->assertGuest();
}
public function test_disabled_account_cannot_keep_using_dingtalk_session(): void
{
$identity = DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-disabled']);
$this->user->update(['enabled' => false]);
$this->actingAs($this->user)->withSession([
'dingtalk_identity_id' => $identity->id,
'dingtalk_roster_checked_at' => now()->timestamp,
])->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized();
$this->assertGuest();
}
public function test_bound_disabled_account_is_not_recreated(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-disabled-login', 'open-disabled'));
DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-disabled-login']);
$this->user->update(['enabled' => false]);
$count = User::count();
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]));
$this->assertGuest();
$this->assertSame($count, User::count());
}
public function test_password_session_for_provisioned_user_cannot_bypass_roster(): void
{
$this->user->update(['created_from' => 'dingtalk_roster']);
DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-password', 'dingtalk_user_id' => 'ding-password']);
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->with('ding-password')->andReturnNull();
$this->actingAs($this->user)->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized();
$this->assertGuest();
}
public function test_roster_outage_during_session_recheck_serves_no_business_data(): void
{
$identity = DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-recheck', 'dingtalk_user_id' => 'ding-recheck']);
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andThrow(new \RuntimeException('fixture outage'));
$this->actingAs($this->user)->withSession(['dingtalk_identity_id' => $identity->id])
->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertStatus(503);
$this->assertAuthenticated();
$this->assertFalse(session()->has('dingtalk_roster_checked_at'));
}
public function test_admin_can_assign_business_access_to_the_automatically_created_user(): void
{
app('url')->defaults(['company_id' => $this->company->id]);
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-grant', 'open-grant'));
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]));
$created = auth()->user();
$this->assertNull(SidebarPermissions::allowedKeys($created));
auth()->logout(); session()->invalidate();
$this->actingAs($this->user)->get(route('system-settings.sidebar.edit', ['subject' => 'user_'.$created->id]))
->assertOk()->assertSee('value="user_'.$created->id.'"', false);
$this->patch(route('system-settings.sidebar.update'), [
'subject' => 'user_'.$created->id, 'permissions' => ['store-management/basic-information'],
])->assertRedirect(route('system-settings.sidebar.edit', ['subject' => 'user_'.$created->id]));
$this->assertSame(['store-management/basic-information'], SidebarPermissions::allowedKeys($created));
$this->actingAs($created)->withSession(['dingtalk_identity_id' => $created->dingtalkIdentity->id, 'dingtalk_roster_checked_at' => now()->timestamp])
->get(route('dashboard'))->assertRedirectContains('/workspace/store-management/basic-information');
$this->withExceptionHandling()->get(route('business.pages.show', [
'company_id' => $this->company->id, 'section' => 'company-management', 'page' => 'basic-information',
]))->assertForbidden();
}
public function test_explicit_empty_user_scope_does_not_fall_back_to_all_pages(): void
{
$this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]);
setting()->set('system.sidebar_permissions', [
'configured' => ['user_'.$this->user->id], 'subjects' => ['user_'.$this->user->id => []],
]);
$this->assertSame([], SidebarPermissions::navigationFor($this->user));
$this->loginAs()->get(route('dashboard', ['company_id' => $this->company->id]))->assertOk()->assertSeeText('尚未分配业务权限');
}
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),
};
};
}
}