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