'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), }; }; } }