财务花名册准入与钉钉授权自动开户 #114

Merged
WuYongJie merged 2 commits from fix/WuYongJie-dingtalk-session-security into main 2026-09-08 06:14:46 +08:00
18 changed files with 682 additions and 90 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Abstracts\Http\Controller;
use App\Models\Auth\DingTalkBindingRequest;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\DingTalkOAuthState;
use App\Services\Auth\DingTalkClosedAccess;
@ -17,8 +18,12 @@ use Throwable;
class DingTalkLogin extends Controller
{
public function redirect(DingTalkOAuthClient $client): RedirectResponse
public function redirect(Request $request, DingTalkOAuthClient $client): RedirectResponse
{
if ($canonical = $this->canonicalRedirect($request, 'login.dingtalk')) {
return $canonical;
}
if (! $client->isConfigured()) {
return redirect()->to($this->applicationRoute('login'))
->with('dingtalk_error', trans('auth.dingtalk.unavailable'));
@ -29,6 +34,7 @@ class DingTalkLogin extends Controller
->delete();
$state = Str::random(64);
$request->session()->put('dingtalk_oauth_state_hash', hash('sha256', $state));
DingTalkOAuthState::query()->create([
'state_hash' => hash('sha256', $state),
'expires_at' => now()->addMinutes(max(1, (int) config('dingtalk.state_ttl_minutes', 5))),
@ -39,6 +45,10 @@ class DingTalkLogin extends Controller
public function callback(Request $request, DingTalkOAuthClient $client): RedirectResponse
{
if ($canonical = $this->canonicalRedirect($request, 'login.dingtalk.callback', $request->only(['state', 'code', 'authCode']))) {
return $canonical;
}
$stateValue = trim((string) $request->query('state', ''));
$authorizationCode = trim((string) ($request->query('authCode') ?: $request->query('code', '')));
@ -46,6 +56,12 @@ class DingTalkLogin extends Controller
return $this->failed();
}
$sessionState = $request->session()->get('dingtalk_oauth_state_hash');
if (! is_string($sessionState) || ! hash_equals($sessionState, hash('sha256', $stateValue))) {
return $this->failed();
}
$request->session()->forget('dingtalk_oauth_state_hash');
$state = DB::transaction(function () use ($stateValue): ?DingTalkOAuthState {
$candidate = DingTalkOAuthState::query()
->where('state_hash', hash('sha256', $stateValue))
@ -87,13 +103,28 @@ class DingTalkLogin extends Controller
return $this->denied();
}
$identity = app(DingTalkClosedAccess::class)->resolve($profile, $request);
try {
$identity = app(DingTalkClosedAccess::class)->resolve($profile, $request);
} catch (Throwable $exception) {
Log::warning('DingTalk finance roster authorization unavailable.', ['exception' => $exception::class]);
return redirect()->to($this->applicationRoute('login'))
->with('dingtalk_error', trans('auth.dingtalk.roster_unavailable'));
}
if ($identity) {
return $this->loginBoundUser($request, $identity, $profile);
}
return $this->denied();
$bindingStatus = DingTalkBindingRequest::query()->where('union_id', $profile['union_id'])->value('status');
$message = $request->attributes->get('dingtalk_denial') ?? match ($bindingStatus) {
DingTalkBindingRequest::PENDING => 'pending',
DingTalkBindingRequest::REJECTED => 'rejected',
DingTalkBindingRequest::REVOKED => 'revoked',
default => 'denied',
};
return redirect()->to($this->applicationRoute('login'))
->with('dingtalk_error', trans('auth.dingtalk.'.$message));
}
protected function loginBoundUser(Request $request, DingTalkIdentity $identity, array $profile): RedirectResponse
@ -109,8 +140,12 @@ class DingTalkLogin extends Controller
return $this->failed();
}
Auth::login($user, true);
Auth::login($user, false);
// Remove any remember cookie left by an earlier login in this browser.
cookie()->queue(cookie()->forget(Auth::guard()->getRecallerName()));
$request->session()->regenerate();
$request->session()->put('dingtalk_identity_id', $identity->id);
$request->session()->put('dingtalk_roster_checked_at', now()->timestamp);
$identity->forceFill([
'open_id' => $profile['open_id'] ?: $identity->open_id,
@ -146,4 +181,18 @@ class DingTalkLogin extends Controller
? $applicationUrl.'/'.ltrim($path, '/')
: route($name, $parameters);
}
private function canonicalRedirect(Request $request, string $route, array $query = []): ?RedirectResponse
{
$target = $this->applicationRoute($route);
if ($request->url() === $target) {
return null;
}
// Legacy registered callbacks may use an IP host. Return to the fixed
// application origin before checking its browser session; never use an
// incoming host or redirect parameter to construct this destination.
return redirect()->to($target.($query ? '?'.http_build_query($query) : ''))
->withHeaders(['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']);
}
}

View File

@ -42,6 +42,7 @@ class Login extends Controller
}
// Get user object
$request->session()->forget(['dingtalk_identity_id', 'dingtalk_roster_checked_at']);
$user = user();
// Check if user is enabled
@ -138,13 +139,8 @@ class Login extends Controller
{
auth()->logout();
// Session destroy is required if stored in database
if (config('session.driver') == 'database') {
$request = app('Illuminate\Http\Request');
$request->session()->invalidate();
$request->session()->regenerateToken();
$request->session()->getHandler()->destroy($request->session()->getId());
}
$request = app('Illuminate\Http\Request');
$request->session()->invalidate();
$request->session()->regenerateToken();
}
}

View File

@ -102,7 +102,9 @@ class Pages extends Controller
{
$section = collect(SidebarPermissions::navigationFor(user()))->first();
abort_if(empty($section), 403, '当前账号没有已授权的侧边栏功能。');
if (empty($section)) {
return view('business.access-pending');
}
$page = collect($section['pages'] ?? [])->first();
$parameters = ['section' => $section['slug']];

View File

@ -79,7 +79,7 @@ class Sidebar extends Controller
$users = User::query()
->whereHas('companies', fn ($company) => $company->where('companies.id', company_id()))
->whereHas('roles', fn ($roles) => $roles->whereIn('roles.name', [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN]))
->whereHas('roles', fn ($roles) => $roles->whereIn('roles.name', [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN, SystemAccess::USER]))
->with('roles')
->orderBy('name')
->get();

View File

@ -35,6 +35,7 @@ class Kernel extends HttpKernel
'cookies.encrypt',
'cookies.response',
'session.start',
\App\Http\Middleware\LogoutIfDingTalkBindingRevoked::class,
// 'session.auth',
'session.errors',
'csrf',

View File

@ -22,9 +22,15 @@ class AddXHeader
$response->header('X-Akaunting', 'Online Accounting Software');
$response->header('X-Content-Type-Options', 'nosniff');
$response->header('X-Frame-Options', 'SAMEORIGIN');
$response->header('Referrer-Policy', 'strict-origin-when-cross-origin');
if (! $response->headers->has('Referrer-Policy')) {
$response->header('Referrer-Policy', 'strict-origin-when-cross-origin');
}
$response->header('X-XSS-Protection', '0');
$response->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if ($request->routeIs('login.dingtalk', 'login.dingtalk.callback')) {
$response->header('Cache-Control', 'no-store');
$response->header('Referrer-Policy', 'no-referrer');
}
}
return $response;

View File

@ -0,0 +1,63 @@
<?php
namespace App\Http\Middleware;
use App\Models\Auth\DingTalkIdentity;
use App\Services\Auth\FinanceRoster;
use App\Services\Auth\DingTalkClosedAccess;
use Closure;
use Throwable;
class LogoutIfDingTalkBindingRevoked
{
public function handle($request, Closure $next)
{
$identityId = $request->session()->get('dingtalk_identity_id');
$user = $request->user();
if (! $identityId) {
if (! $user || $user->created_from !== 'dingtalk_roster') {
return $next($request);
}
// A password reset must not bypass HR eligibility for provisioned
// accounts. Apply the same boundary after every sign-in method.
$identityId = $user->dingtalkIdentity?->id;
$request->session()->put('dingtalk_identity_id', $identityId);
$request->session()->forget('dingtalk_roster_checked_at');
}
$identity = $user ? DingTalkIdentity::query()->whereKey($identityId)->where('user_id', $user->id)->first() : null;
if ($user && $user->enabled && $identity) {
$checkedAt = (int) $request->session()->get('dingtalk_roster_checked_at', 0);
if ($checkedAt > now()->timestamp - 300) {
return $next($request);
}
try {
$eligible = app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
} catch (Throwable) {
// Retain the session during an HR outage, but do not serve data
// using an expired authorization decision.
return response(trans('auth.dingtalk.roster_unavailable'), 503)
->header('Cache-Control', 'no-store');
}
if ($eligible) {
$request->session()->put('dingtalk_roster_checked_at', now()->timestamp);
return $next($request);
}
}
app(DingTalkClosedAccess::class)->audit('session_revoked', 'denied', $user?->id, null, $identity?->union_id, $request, [
'reason' => ! $user?->enabled ? 'local_account_ineligible' : (! $identity ? 'binding_revoked' : 'outside_finance_roster'),
]);
auth()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
if ($request->expectsJson()) {
return response()->json(['message' => trans('auth.dingtalk.session_expired')], 401)
->header('Cache-Control', 'no-store');
}
return redirect()->route('login')
->with('dingtalk_error', trans('auth.dingtalk.session_expired'));
}
}

View File

@ -6,8 +6,14 @@ use App\Models\Auth\DingTalkAuthAudit;
use App\Models\Auth\DingTalkBindingRequest;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\User;
use App\Models\Auth\Role;
use App\Models\Auth\Permission;
use App\Models\Common\Company;
use App\Models\Common\Contact;
use App\Utilities\SystemAccess;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class DingTalkClosedAccess
{
@ -21,6 +27,15 @@ class DingTalkClosedAccess
return null;
}
$roster = app(FinanceRoster::class)->findEligible((string) ($profile['dingtalk_user_id'] ?? ''));
if (! $roster) {
$this->audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [
'reason' => 'outside_finance_roster',
]);
$request->attributes->set('dingtalk_denial', 'outside_roster');
return null;
}
$identity = DingTalkIdentity::query()
->with('user')
->where('union_id', $profile['union_id'])
@ -54,38 +69,69 @@ class DingTalkClosedAccess
return null;
}
[$candidate, $reason] = $this->uniqueCandidate($profile);
if ($candidate && ! $candidate->dingtalkIdentity) {
return DB::transaction(function () use ($candidate, $profile, $request): DingTalkIdentity {
$identity = DingTalkIdentity::query()->create([
'user_id' => $candidate->id,
'union_id' => $profile['union_id'],
'open_id' => $profile['open_id'],
'dingtalk_user_id' => $profile['dingtalk_user_id'],
'nickname' => $profile['nickname'],
'bound_by' => null,
'bound_at' => now(),
return DB::transaction(function () use ($profile, $roster, $request): ?DingTalkIdentity {
// Serialize first-time provisioning on the target company. Both the
// account and unique identity are committed together, including retries.
$companies = Company::enabled()
->when(config('dingtalk.company_id'), fn ($q, $id) => $q->whereKey($id))
->orderBy('id')->limit(2)->lockForUpdate()->get();
if ($companies->count() !== 1) {
$request->attributes->set('dingtalk_denial', 'unavailable');
return null;
}
$existing = DingTalkIdentity::query()->with('user')->where('union_id', $profile['union_id'])->first();
if ($existing) {
return $this->isEligible($existing->user) ? $existing : null;
}
$binding = DingTalkBindingRequest::query()->where('union_id', $profile['union_id'])->lockForUpdate()->first();
if ($binding && in_array($binding->status, [DingTalkBindingRequest::REJECTED, DingTalkBindingRequest::REVOKED], true)) {
return null;
}
// Never claim a local account by a shared name, email or phone number.
if (DingTalkIdentity::query()->where('dingtalk_user_id', $profile['dingtalk_user_id'])->exists()) {
return null;
}
$role = Role::where('name', SystemAccess::USER)->firstOrFail();
$email = $this->normalizeEmail($profile['email'] ?? null);
if (! $email || User::withTrashed()->where('email', $email)->exists()) {
$email = 'ding-'.hash('sha256', $profile['union_id']).'@dingtalk.invalid';
}
// A deleted/disabled account must not be silently resurrected.
if (User::withTrashed()->where('email', $email)->exists()) {
return null;
}
$account = User::create([
'name' => Str::limit($profile['nickname'] ?: $roster['display_name'], 191, ''),
'email' => $email,
'password' => Str::random(64),
'locale' => 'zh-CN',
'enabled' => true,
'landing_page' => 'dashboard',
'created_by' => 0,
'created_from' => 'dingtalk_roster',
]);
$account->roles()->attach($role->id);
// Shell access only; no financial pages or administrative privileges.
$account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
$account->companies()->attach($companies->first()->id);
if ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) {
Contact::create([
'company_id' => $companies->first()->id, 'type' => Contact::EMPLOYEE_TYPE,
'user_id' => $account->id, 'name' => $account->name, 'email' => $email,
'phone' => $mobile, 'currency_code' => $companies->first()->currency ?: 'CNY',
'enabled' => true, 'created_by' => 0, 'created_from' => 'dingtalk_roster',
]);
$this->upsertRequest($profile, DingTalkBindingRequest::APPROVED, 'automatic_unique_match', $candidate->id);
$this->audit('binding_auto_approved', 'success', $candidate->id, null, $profile['union_id'], $request, [
'match' => 'verified_email_or_mobile',
]);
return $identity->load('user');
});
}
if ($candidate?->dingtalkIdentity) {
$reason = 'local_account_already_bound';
}
$this->upsertRequest($profile, DingTalkBindingRequest::PENDING, $reason, $candidate?->id);
$this->audit('binding_pending', 'pending', $candidate?->id, null, $profile['union_id'], $request, [
'reason' => $reason,
]);
return null;
}
$identity = DingTalkIdentity::create([
'user_id' => $account->id, 'union_id' => $profile['union_id'],
'open_id' => $profile['open_id'], 'dingtalk_user_id' => $profile['dingtalk_user_id'],
'nickname' => $profile['nickname'], 'bound_at' => now(),
]);
$this->upsertRequest($profile, DingTalkBindingRequest::APPROVED, 'finance_roster_provisioned', $account->id);
$this->audit('account_auto_created', 'success', $account->id, null, $profile['union_id'], $request);
return $identity->load('user');
}, 3);
}
public function recordLogin(DingTalkIdentity $identity, Request $request): void
@ -114,31 +160,6 @@ class DingTalkClosedAccess
]);
}
private function uniqueCandidate(array $profile): array
{
$email = $this->normalizeEmail($profile['email'] ?? null);
$mobile = $this->normalizeMobile($profile['mobile'] ?? null);
$eligible = User::query()
->with(['contact', 'dingtalkIdentity'])
->where('enabled', true)
->whereHas('companies', fn ($company) => $company->where('companies.enabled', true))
->get();
$emailMatches = $email
? $eligible->filter(fn (User $user) => $this->normalizeEmail($user->email) === $email)
: collect();
$mobileMatches = $mobile
? $eligible->filter(fn (User $user) => $this->normalizeMobile($user->contact?->phone) === $mobile)
: collect();
$matches = $emailMatches->merge($mobileMatches)->unique('id')->values();
if ($matches->count() === 1) {
return [$matches->first(), 'unique_match'];
}
return [null, $matches->isEmpty() ? 'no_unique_account' : 'multiple_accounts'];
}
private function isEligible(?User $user): bool
{
return $user

View File

@ -0,0 +1,108 @@
<?php
namespace App\Services\Auth;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
/** Single-person, current HR authorization lookup; no HR rows or stale grants are cached. */
class FinanceRoster
{
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
{
}
public function findEligible(string $userId): ?array
{
if (trim($userId) === '') {
return null;
}
$query = $this->queries->forSource('operations_replica');
$flowers = $query->select(
'SELECT TOP (2) Id, name, flowerName, deptId, status, empStatus, leaveDate
FROM rpt.hr_flowerName_book WHERE flowerNameId = ? AND leaveDate IS NULL
ORDER BY Id DESC', [$userId]
);
if (count($flowers) !== 1) {
return null;
}
$flower = $flowers[0];
if ($this->isResigned($flower['status'] ?? null) || $this->isResigned($flower['empStatus'] ?? null)) {
return null;
}
if ($query->first('SELECT TOP (1) userid FROM rpt.hr_employee_dimission WHERE userid = ?', [$userId])) {
return null;
}
$employees = $query->select('SELECT TOP (2) active, dept_id_list FROM rpt.hr_employee WHERE userid = ?', [$userId]);
if (count($employees) > 1 || (isset($employees[0]['active']) && ! (bool) $employees[0]['active'])) {
return null;
}
$registrations = $query->select(
'SELECT TOP (20) [sys01-employeeStatus] AS employment_status, [sys00-deptIds] AS department_ids,
[sys00-mainDeptId] AS main_department_id FROM rpt.hr_employee_register WHERE userid = ?', [$userId]
);
if (count($registrations) === 20) {
return null;
}
$departmentIds = $this->departmentIds($flower['deptId'] ?? null);
foreach ($employees as $employee) {
$departmentIds = array_merge($departmentIds, $this->departmentIds($employee['dept_id_list'] ?? null));
}
foreach ($registrations as $registration) {
if ($this->isResigned($registration['employment_status'] ?? null)) {
return null;
}
$departmentIds = array_merge($departmentIds,
$this->departmentIds($registration['department_ids'] ?? null),
$this->departmentIds($registration['main_department_id'] ?? null));
}
$departmentIds = array_values(array_unique($departmentIds));
if ($departmentIds === [] || count($departmentIds) > 64) {
return null;
}
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
$ancestors = $query->select(
'WITH lineage AS (
SELECT Id, parent_id, 0 AS depth FROM rpt.hr_groups WHERE Id IN ('.$placeholders.')
UNION ALL
SELECT parent.Id, parent.parent_id, child.depth + 1
FROM rpt.hr_groups parent JOIN lineage child ON parent.Id = child.parent_id
WHERE child.depth < 20
) SELECT TOP (2048) Id, parent_id, depth FROM lineage', $departmentIds
);
if (count($ancestors) === 2048 || array_filter($ancestors, fn ($row) => (int) $row['depth'] >= 20)) {
return null;
}
$allowed = array_map('strval', config('dingtalk.finance_department_ids', []));
if (! array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')))) {
return null;
}
return [
'roster_id' => (string) $flower['Id'],
'display_name' => trim((string) ($flower['flowerName'] ?: $flower['name'])),
'department_ids' => $departmentIds,
];
}
private function isResigned(mixed $value): bool
{
$status = trim((string) $value);
return $status === '5' || preg_match('/离职|停用|退休/u', $status) === 1;
}
private function departmentIds(mixed $value): array
{
$value = trim((string) $value);
$parts = json_decode($value, true);
if (! is_array($parts)) {
$parts = preg_split('/[\[\]\/>,,;;||\s]+/u', $value, -1, PREG_SPLIT_NO_EMPTY);
}
return array_values(array_filter(array_map(
fn ($id) => is_scalar($id) ? trim((string) $id) : '', $parts
), fn ($id) => ctype_digit($id) && (int) $id > 0));
}
}

View File

@ -35,6 +35,9 @@ class SidebarPermissions
$subject = self::subjectKey($user);
if (! in_array($subject, $settings['configured'] ?? [], true)) {
if ($user->created_from === 'dingtalk_roster' && SystemAccess::roleName($user) === SystemAccess::USER) {
return [];
}
return null;
}

View File

@ -7,9 +7,14 @@ return [
'client_secret' => env('DINGTALK_CLIENT_SECRET'),
'redirect_uri' => env(
'DINGTALK_REDIRECT_URI',
'http://218.93.190.75:8442/auth/dingtalk/callback'
rtrim(env('APP_URL', 'https://caiwu.jiansuntuzhi.cn:8442'), '/').'/auth/dingtalk/callback'
),
// Verified HR roots: 简尚财务中心 and 龙晨品牌中心/财务中心.
'finance_department_ids' => array_values(array_filter(array_map('trim', explode(',', env('DINGTALK_FINANCE_DEPARTMENT_IDS', '22848291,994080505'))))),
// When omitted, automatic provisioning requires exactly one enabled company.
'company_id' => env('DINGTALK_COMPANY_ID'),
'authorize_url' => env('DINGTALK_AUTHORIZE_URL', 'https://login.dingtalk.com/oauth2/auth'),
'token_url' => env('DINGTALK_TOKEN_URL', 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'),
'user_url' => env('DINGTALK_USER_URL', 'https://api.dingtalk.com/v1.0/contact/users/me'),

View File

@ -0,0 +1,63 @@
# 钉钉授权与财务花名册自动开户
## 用户流程
首次钉钉授权成功后,系统用企业 `userid` 对应 HR 花名册中的 `flowerNameId`,核验人员状态和组织归属,自动创建普通用户、绑定钉钉身份并登录。不要求填写注册表、设置密码或等待账号绑定审批。
准入组织为负责人确认的两棵子树:
- `22848291`:江苏简尚品牌管理有限公司 / 财务中心,包含财务部及其他下级部门。
- `994080505`:江苏简尚品牌管理有限公司 / 龙晨品牌中心 / 财务中心,包含下级部门。
按部门 ID 的祖先关系判断,支持花名册、员工表和任职登记中的兼任部门;同名部门不会自动获得准入。花名册缺失、身份多义、明确离职、停用或组织链异常时不开户。钉钉企业接口还必须确认该身份是有效的在职企业成员。
已绑定的启用账号继续使用原账号及原权限。姓名、花名、邮箱和手机号不会用于自动认领其他账号。已拒绝、解绑或停用的身份不会通过再次授权自动恢复。
新账号使用钉钉接口返回的姓名、可用邮箱和手机号;邮箱缺失或已被其他账号使用时,生成稳定的 `@dingtalk.invalid` 内部占位地址。系统生成不可预知的随机本地密码,不展示或发送密码,不发送注册邀请。花名册显示名仅作为姓名缺失时的后备。
新账号赋予现有 `user` 角色及进入应用外壳所需的 `read-admin-panel` 权限,不添加管理权限。业务访问继承管理员明确配置的普通用户权限;尚无配置时默认无业务权限,进入“你已登录,等待分配业务权限”的工作台。管理员可在“侧边栏管理”中为普通用户角色或指定账号配置业务范围。
## 只读数据契约
复用 `operations_replica` 的共享连接和 `UpstreamReadOnlyQueryFactory`,固定读取:
| 对象 | 字段与用途 | 单次上限 |
| --- | --- | --- |
| `rpt.hr_flowerName_book` | `Id/name/flowerName/deptId/status/empStatus/leaveDate`,按 `flowerNameId = userid`,无离职日期,按 Id 降序;要求唯一有效身份 | 2 行 |
| `rpt.hr_employee_dimission` | 按 `userid` 检查明确离职记录 | 1 行 |
| `rpt.hr_employee` | `active/dept_id_list`,按 `userid` | 2 行 |
| `rpt.hr_employee_register` | `sys01-employeeStatus/sys00-deptIds/sys00-mainDeptId`,按 `userid` | 20 行,达到上限即拒绝 |
| `rpt.hr_groups` | `Id/parent_id`,从最多 64 个任职部门沿祖先链查找 | 深度小于 20,最多 2048 行,达到边界即拒绝 |
离职信号优先于其他状态:离职记录、状态码 `5`、含“离职/停用/退休”的状态均拒绝。花名册中的在职口径及钉钉有效企业身份共同约束访问。
这是按单人执行的当前身份授权查询,不是业务页面报表查询。不会建立另一份 HR 全量镜像,也不会把上游原始 HR 行写入系统配置、业务镜像、ClickHouse 或 Parquet。首次登录及每 5 分钟会话复核读取当前来源;来源故障时不得使用已过期的授权结果继续提供业务数据。
## 会话边界
- OAuth state 同时绑定浏览器会话,并校验数据库中的过期和一次性消费状态。跨浏览器和重放均不能兑换授权码。
- 正式回调默认是 `APP_URL/auth/dingtalk/callback`。旧 IP 回调先跳回固定应用域名,再核验域名下的会话,不接受外部跳转目标。
- OAuth 响应采用 `no-store` 和 `no-referrer`。钉钉登录不签发长期 remember cookie,并清除浏览器中的旧 remember cookie。
- 新钉钉会话每次请求核验账号启用状态和绑定记录。解绑或停用后,下一次请求失效;花名册离职或调离财务范围在下一次 5 分钟复核时失效。
- 自动开户账号即使改用密码登录,也须经过同样的花名册检查,不能通过密码重置绕过准入。
- 开户、登录拒绝、登录成功、解绑和会话失效有审计;不记录授权码、访问令牌、随机密码或完整 HR 响应。
## 配置与发布边界
本次没有新增数据库结构或镜像迁移。以下生产操作需要发布授权后执行:
1. 发布已合并的 main,并重建应用配置缓存。
2. 将生产 `DINGTALK_REDIRECT_URI` 设置为 `https://caiwu.jiansuntuzhi.cn:8442/auth/dingtalk/callback`,核对钉钉开放平台对应应用的回调配置。2026-09-08 的只读检查确认生产 `APP_URL` 已是正式域名,但回调变量仍为旧 IP 地址;不能只改代码默认值就宣称生产已切换。
3. 首次切换须处理旧版已签发的会话与 remember 令牌。旧会话没有钉钉身份标记,不能可靠区分密码登录与钉钉登录;在备份后统一失效旧登录态会要求用户重新登录,须纳入生产授权。当前生产 session driver 为 `file`。回滚旧镜像无法恢复已失效的浏览器登录态。
4. `DINGTALK_COMPANY_ID` 不配置时只允许唯一启用公司;2026-09-08 只读检查确认生产仅公司 ID 1 启用。若新增公司,需明确配置自动开户的目标公司,不取任意第一条。
5. 两个财务根部门可通过 `DINGTALK_FINANCE_DEPARTMENT_IDS` 显式配置。扩大组织范围属于权限变更。
代码回滚使用上一应用镜像;已自动建立的账号和审计保留,不执行账号删除。真实验收只需一名范围内人员完成首次授权、再次登录及获授权后的工作台访问;不得使用管理员身份代替首次开户验证。
## 验证证据(2026-09-08)
- 39 项测试、197 个断言通过,使用隔离内存 SQLite 和虚构钉钉/花名册资料,覆盖开户、重复登录、同邮箱/手机号不认领、跨浏览器、重放、权限分配、非财务/离职/停用/解绑、HR 故障、密码登录不绕过及管理员回归。
- 开发节点通过实际只读入口抽查 3 名有有效钉钉唯一值的财务花名册人员,3 名均被新准入查询识别;不输出人员身份、手机号或原始 HR 行。
- 生产节点使用现有只读查询入口读到两个确认的财务根部门,说明该节点可独立访问 HR 来源。本次没有在生产执行开户或写入。
- 浏览器检查实际 Blade 渲染的虚构账号页面:桌面和 390px 窄屏首次登录提示、管理员绑定页新说明和开户审计。权限保存与后续工作台跳转通过 HTTP 功能测试验证。
- 尚未发布,尚未进行真实钉钉扫码开户验收。

View File

@ -54,6 +54,12 @@ return [
'unavailable' => 'DingTalk sign-in is not enabled. Please use email and password.',
'failed' => 'DingTalk sign-in did not complete. Please start the authorization again.',
'denied' => 'This DingTalk account is not authorized for the finance system. Contact the system administrator.',
'pending' => 'Your DingTalk identity is verified. Account binding is awaiting administrator approval. Sign in again after approval.',
'rejected' => 'Your DingTalk binding request was rejected. Contact the system administrator for access.',
'revoked' => 'Your DingTalk account binding was removed. Contact the system administrator to restore access.',
'session_expired' => 'Your DingTalk session is no longer valid. Sign in again, or contact the administrator if your account was disabled or unbound.',
'outside_roster' => 'Access is limited to current staff in the finance departments listed in the HR roster. Contact an administrator if your record needs correction.',
'roster_unavailable' => 'The finance roster cannot be verified right now. Please try DingTalk sign-in again later.',
],
'notification' => [

View File

@ -54,6 +54,12 @@ return [
'unavailable' => '钉钉登录尚未启用,请暂时使用邮箱密码登录。',
'failed' => '钉钉登录未完成,请重新发起授权。',
'denied' => '当前钉钉账号未获财务系统授权,请联系系统管理员。',
'pending' => '钉钉身份已确认,账号绑定申请正在等待管理员审批。审批通过后请重新登录。',
'rejected' => '钉钉账号绑定申请已被拒绝,如需开通请联系系统管理员。',
'revoked' => '钉钉账号绑定已解除,如需恢复请联系系统管理员。',
'session_expired' => '钉钉登录已失效,请重新登录;如账号已停用或解除绑定,请联系系统管理员。',
'outside_roster' => '当前仅向花名册中财务中心及其下级部门的在职人员开放。如人员资料有误,请联系管理员核对花名册。',
'roster_unavailable' => '暂时无法核验财务花名册,请稍后重新尝试钉钉登录。',
],
'notification' => [

View File

@ -0,0 +1,10 @@
<x-layouts.admin>
<x-slot name="title">工作台</x-slot>
<x-slot name="content">
<section class="my-6 rounded-xl border border-[#D8D2C7] bg-white p-6" aria-labelledby="access-title">
<h1 id="access-title" class="text-sm font-semibold text-[#171717]">{{ user()->name }},你已登录</h1>
<p class="mt-3 text-sm text-[#595959]">当前账号尚未分配业务权限,请联系管理员开通需要使用的功能。</p>
<p class="mt-2 text-sm text-[#595959]">权限分配后,重新打开工作台即可使用,无需再次注册。</p>
</section>
</x-slot>
</x-layouts.admin>

View File

@ -6,7 +6,7 @@
@include('system_settings.partials.navigation')
<div class="mb-5 rounded-xl border border-[#D8D2C7] bg-[#F7F0DE] px-4 py-3 text-sm text-[#595959]">
钉钉登录不会自动注册账号。只有企业内在职人员与现有启用财务账号唯一匹配时才会自动绑定;其他申请需管理员确认。
花名册中两个财务中心及其下级部门的在职人员,首次钉钉授权后会自动创建普通账号并登录,无需另行注册。业务权限由系统管理员分配;已拒绝、解绑或停用的账号不会自动恢复。
</div>
@if ($errors->any())
@ -20,6 +20,8 @@
$eventLabels = [
'binding_pending' => '提交绑定申请',
'binding_auto_approved' => '自动绑定',
'account_auto_created' => '钉钉自动开户',
'session_revoked' => '登录会话失效',
'binding_admin_approved' => '管理员绑定',
'binding_rejected' => '拒绝绑定',
'binding_revoked' => '解除绑定',

View File

@ -7,6 +7,8 @@ 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;
@ -24,7 +26,7 @@ class DingTalkLoginTest extends FeatureTestCase
'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.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',
@ -32,6 +34,11 @@ class DingTalkLoginTest extends FeatureTestCase
'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
@ -43,7 +50,7 @@ class DingTalkLoginTest extends FeatureTestCase
->assertDontSeeText('首次使用钉钉登录,请输入现有财务系统账号和密码完成绑定');
}
public function test_redirect_creates_hashed_state_and_uses_registered_http_callback(): void
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);
@ -88,7 +95,7 @@ class DingTalkLoginTest extends FeatureTestCase
Http::assertSentCount(5);
}
public function test_unique_verified_email_auto_binds_existing_enabled_account(): void
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'));
@ -100,25 +107,31 @@ class DingTalkLoginTest extends FeatureTestCase
'state' => $state,
]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442');
$this->assertAuthenticatedAs($this->user);
$created = auth()->user();
$this->assertNotSame($this->user->id, $created->id);
$this->assertTrue($created->hasRole('user'));
$this->assertFalse($created->hasRole('super_admin'));
$this->assertSame([], SidebarPermissions::allowedKeys($created));
$this->assertDatabaseHas('dingtalk_identities', [
'user_id' => $this->user->id,
'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' => $this->user->id,
'matched_user_id' => $created->id,
]);
$this->assertDatabaseHas('dingtalk_auth_audits', [
'event' => 'binding_auto_approved',
'event' => 'account_auto_created',
'outcome' => 'success',
'user_id' => $this->user->id,
'user_id' => $created->id,
]);
$this->get(route('dashboard', ['company_id' => $this->company->id]))->assertOk()
->assertSeeText('你已登录')->assertSeeText('无需再次注册');
}
public function test_unique_verified_mobile_auto_binds_existing_enabled_account(): void
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'));
@ -141,14 +154,14 @@ class DingTalkLoginTest extends FeatureTestCase
'state' => $state,
]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442');
$this->assertAuthenticatedAs($this->user);
$this->assertNotSame($this->user->id, auth()->id());
$this->assertDatabaseHas('dingtalk_identities', [
'user_id' => $this->user->id,
'user_id' => auth()->id(),
'union_id' => 'union-mobile',
]);
}
public function test_unmatched_enterprise_user_creates_pending_request_without_login_or_raw_contact_data(): void
public function test_finance_staff_receive_api_profile_and_no_manual_registration_step(): void
{
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses(
@ -162,14 +175,16 @@ class DingTalkLoginTest extends FeatureTestCase
$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'));
]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442');
$this->assertGuest();
$this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-pending']);
$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' => 'pending',
'status' => 'approved',
'email_hint' => 'o***@example.test',
'mobile_hint' => '*******8000',
]);
@ -253,6 +268,7 @@ class DingTalkLoginTest extends FeatureTestCase
'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',
@ -263,6 +279,176 @@ class DingTalkLoginTest extends FeatureTestCase
$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
{
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->withExceptionHandling()->get(route('business.pages.show', [
'company_id' => $this->company->id, 'section' => 'store-management', 'page' => 'basic-information',
]))->assertForbidden();
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('dashboard'));
$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');
}
protected function startAuthorization(): string
{
$response = $this->get(route('login.dingtalk'))->assertRedirect();

View File

@ -0,0 +1,65 @@
<?php
namespace Tests\Unit\Auth;
use App\Services\Auth\FinanceRoster;
use Mockery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class FinanceRosterTest extends TestCase
{
#[DataProvider('rosterCases')]
public function test_roster_authorization(array $overrides, bool $expected): void
{
config(['dingtalk.finance_department_ids' => ['22848291', '994080505']]);
$data = array_replace([
'flowers' => [['Id' => 123, 'name' => '测试员工', 'flowerName' => '测试花名', 'deptId' => '55248182', 'status' => '', 'empStatus' => '正式']],
'employees' => [['active' => 1, 'dept_id_list' => '[55248182]']],
'registrations' => [['employment_status' => '3', 'department_ids' => '55248182', 'main_department_id' => '55248182']],
'resignation' => null,
'ancestors' => [['Id' => '55248182', 'parent_id' => '22848291', 'depth' => 0], ['Id' => '22848291', 'parent_id' => '1', 'depth' => 1]],
], $overrides);
$query = Mockery::mock(UpstreamReadOnlyQuery::class);
$query->shouldReceive('select')->andReturnUsing(function ($sql, $bindings) use ($data) {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
if (str_contains($sql, 'WITH lineage')) {
$this->assertContains('55248182', $bindings);
return $data['ancestors'];
}
$this->assertSame(['ding-fixture-id'], $bindings);
return match (true) {
str_contains($sql, 'hr_flowerName_book') => $data['flowers'],
str_contains($sql, 'hr_employee_register') => $data['registrations'],
str_contains($sql, 'hr_employee') => $data['employees'],
};
});
$query->shouldReceive('first')->with(Mockery::on(function ($sql) {
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
return str_contains($sql, 'hr_employee_dimission');
}), ['ding-fixture-id'])->andReturn($data['resignation']);
$factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class);
$factory->shouldReceive('forSource')->with('operations_replica')->andReturn($query);
$result = (new FinanceRoster($factory))->findEligible('ding-fixture-id');
$this->assertSame($expected, $result !== null);
}
public static function rosterCases(): array
{
return [
'finance descendant' => [[], true],
'second finance root' => [['ancestors' => [['Id' => '994080505', 'parent_id' => '994121400', 'depth' => 1]]], true],
'concurrent finance membership' => [['employees' => [['active' => 1, 'dept_id_list' => '[100,55248182]']]], true],
'not in roster' => [['flowers' => []], false],
'same department name outside allowed roots' => [['ancestors' => [['Id' => '123456', 'parent_id' => '1', 'depth' => 0]]], false],
'resignation beats active status' => [['resignation' => ['userid' => 'ding-fixture-id']], false],
'numeric resigned status' => [['registrations' => [['employment_status' => '5']]], false],
'disabled employee' => [['employees' => [['active' => 0]]], false],
'flower-only member' => [['employees' => [], 'registrations' => []], true],
'ambiguous roster identity' => [['flowers' => [['Id' => 1], ['Id' => 2]]], false],
'broken department cycle' => [['ancestors' => [['Id' => '22848291', 'parent_id' => '22848291', 'depth' => 20]]], false],
];
}
}