diff --git a/src/dashboard/bot-onboarding.ts b/src/dashboard/bot-onboarding.ts index ba62557a0..270cd66ca 100644 --- a/src/dashboard/bot-onboarding.ts +++ b/src/dashboard/bot-onboarding.ts @@ -1542,6 +1542,10 @@ export class BotOnboardingManager { // 这份 ACK 是从权限台账重建的,本次并没有碰 redirect 白名单。宁可报「没配」 // 让人去核一眼,也不能凭空报「配好了」——那正是 20029 静默失败的来源。 redirectConfigured: false, + // 同上:本次没有读/写「权限可访问的数据范围」。0 + warning 才是诚实的 + // 「没碰过」,报 0 而不带 warning 会被下游读成「本来就没有待配的」。 + privilegeRangeCount: 0, + privilegeRangeWarning: '本次从权限台账重建,未读写权限数据范围', eventMode: managedPermission.eventMode, verifiedEventCount: managedPermission.verifiedEventCount, versionId: managedPermission.versionId, diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index 32b2d7c4a..a480bee20 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -303,8 +303,15 @@ async function tryAutoFixScopes( : '所有必需权限已在应用清单中'; // 一项都没申请上时,这次自愈**没有**修好任何东西,日志级别也不该是「succeeded」。 const autoFixEffective = result.scopeCount > 0 || (!result.scopeWarning && result.skippedScopeCount === 0); + // 数据范围只在**真做了事**或**真出错**时才进日志:常态是「没有待配的」, + // 那句话对读日志的人零信息量,还会淹没上面真正的 scope 结论。 + const privilegeRangeDetail = result.privilegeRangeCount > 0 + ? `, ${result.privilegeRangeCount} 项权限数据范围已设为「与应用的可用范围一致」` + : result.privilegeRangeWarning + ? `, 权限数据范围未能自动配置(${result.privilegeRangeWarning})` + : ''; const summary = - `[${larkAppId}] auto-fix ${autoFixEffective ? 'succeeded' : 'could NOT apply the missing scopes'}: ${scopeDetail}, ` + + `[${larkAppId}] auto-fix ${autoFixEffective ? 'succeeded' : 'could NOT apply the missing scopes'}: ${scopeDetail}${privilegeRangeDetail}, ` + `version ${result.versionId ?? 'n/a'} published, ` + `${result.subscribedEventCount} events subscribed`; if (autoFixEffective) logger.info(summary); diff --git a/src/setup/open-platform-automation.ts b/src/setup/open-platform-automation.ts index 85882ade4..84b8ff2c9 100644 --- a/src/setup/open-platform-automation.ts +++ b/src/setup/open-platform-automation.ts @@ -125,6 +125,41 @@ export interface MappedScopeIds { missingUserScopes: string[]; } +/** console 的 `schemaType` 枚举:只有 SelectionExpression 这档有「数据范围」表单。 */ +export const PRIVILEGE_SCHEMA_TYPE_SELECTION_EXPRESSION = 1; +/** console 的 `organizationType` 枚举:跨组织(B2B/B2C)那两档不在本机制内。 */ +export const PRIVILEGE_ORG_TYPE_INTERNAL = 1; + +/** 数据范围表单里的一个字段(只保留判定与拼 content 需要的部分)。 */ +export interface OpenPlatformPrivilegeField { + id: string; + name: string; + /** `data_source.type === 'select_staff'`,即「选人」控件。 */ + selectStaff: boolean; + /** 支持 `in`(「包含」)操作符。 */ + supportsIn: boolean; +} + +/** `privilege/all` 里的一条「权限可访问的数据范围」。 */ +export interface OpenPlatformPrivilege { + /** 原始条目,写回时浅拷贝改 content 用(服务端还会读其它字段)。 */ + raw: Record; + bizId: string; + resource: string; + name: string; + /** 所属业务分类的显示名(来自同一响应的 `scopeBiz`),只用于 description。 */ + bizName: string; + isRequired: boolean; + content: string; + schemaType?: number; + organizationType?: number; + fields: OpenPlatformPrivilegeField[]; +} + +export interface OpenPlatformPrivilegeState { + privileges: OpenPlatformPrivilege[]; +} + export type OpenPlatformAutomationResult = | { ok: true; @@ -134,6 +169,14 @@ export type OpenPlatformAutomationResult = scopeCount: number; skippedScopeCount: number; scopeWarning?: string; + /** + * 自动填好的「权限可访问的数据范围」条数(填成「与应用的可用范围一致」)。 + * 0 有两种成因:本来就没有待填的(常态),或写入失败(看 + * {@link privilegeRangeWarning})——两者必须靠 warning 区分,别照 count 报「已配齐」。 + */ + privilegeRangeCount: number; + /** 数据范围读取/写入失败的原因(非致命,仅影响后续审批快慢)。 */ + privilegeRangeWarning?: string; subscribedEventCount: number; eventWarning?: string; /** 回读后仍缺失的 VC 会议事件。普通建 bot 不阻断,VC listener 保存前必须为空。 */ @@ -445,6 +488,206 @@ export function filterScopeManifest(manifest: ScopeManifest, wantedNames: string }; } +/** + * 从 `POST /developers/v1/privilege/all/` 的返回里解析「权限可访问的数据 + * 范围」条目。 + * + * ⚠️ 这是**独立于 scope/update 的第二条链路**:`scope/update` 只把权限点加进 + * 应用清单,而每个权限点还可能带一份「这个权限能看到哪些数据」的配置(console + * 上是权限详情里的「权限可访问的数据范围」单选:全部 / 与应用的可用范围一致 / + * 按条件筛选)。两者的 appId 相同但接口、payload、生效时机全不一样。 + * + * 第三条相关链路是 `contact_range`(通讯录权限范围),又是另一个概念,不在这里。 + */ +export function extractOpenPlatformPrivileges(payload: unknown): OpenPlatformPrivilegeState { + const data = asRecord(asRecord(payload).data); + const rawPrivileges = Array.isArray(data.privileges) ? data.privileges : []; + const rawBizNames = Array.isArray(data.scopeBiz) ? data.scopeBiz : []; + const bizNames = new Map(); + for (const biz of rawBizNames) { + const record = asRecord(biz); + const bizId = pickString(record, ['bizId', 'biz_id']); + const bizName = pickString(record, ['bizName', 'biz_name']); + if (bizId && bizName) bizNames.set(bizId, bizName); + } + const privileges: OpenPlatformPrivilege[] = []; + for (const entry of rawPrivileges) { + const record = asRecord(entry); + const bizId = pickString(record, ['bizId', 'biz_id']); + // resource 允许为空串(contact 这类整 biz 一条的形态),但 bizId 必须有: + // 缺了它连合并键都拼不出来,写回去也定位不到条目。 + if (!bizId) continue; + privileges.push({ + raw: record, + bizId, + resource: pickString(record, ['resource']) ?? '', + name: pickString(record, ['name']) ?? '', + bizName: bizNames.get(bizId) ?? '', + isRequired: record.isRequired === true, + content: pickString(record, ['content']) ?? '', + schemaType: typeof record.schemaType === 'number' ? record.schemaType : undefined, + organizationType: typeof record.organizationType === 'number' ? record.organizationType : undefined, + fields: extractPrivilegeStaffFields(record), + }); + } + return { privileges }; +} + +/** + * 「与应用的可用范围一致」在飞书 console 里的内部取值。console 前端把这三个 + * mode 定义在同一个 enum 上(`availability_of_app` / `part` / `all`),选中第 + * 二项时写进 filter value 的就是这个字符串。 + */ +export const PRIVILEGE_RANGE_SAME_AS_APP_AVAILABILITY = 'availability_of_app'; + +/** + * 判断某条数据范围**能不能**用「与应用的可用范围一致」自动填。 + * + * console 的判据(`em()` / `om()`,CDP 读前端 bundle 确认)是 + * `schemaType === SelectionExpression(1) && organizationType === InternalOrganization(1)`; + * 在此之上本函数额外要求每个字段都是「选人」类型且支持 `in` 操作符,因为 + * `availability_of_app` 是**成员范围**语义——把它塞进「工作地点」这类字符串 + * 字段是无意义的(DLP 那条 privilege 就同时有 `member_range` 和 `place` 两个 + * 字段)。字段里只要有一个不满足就整条跳过,宁可留给人手配,也不猜一个可能 + * 被审核驳回的组合。 + */ +export function canFillPrivilegeWithAppAvailability(privilege: OpenPlatformPrivilege): boolean { + if (privilege.schemaType !== PRIVILEGE_SCHEMA_TYPE_SELECTION_EXPRESSION) return false; + if (privilege.organizationType !== PRIVILEGE_ORG_TYPE_INTERNAL) return false; + if (!privilege.fields.length) return false; + return privilege.fields.every(field => field.selectStaff && field.supportsIn); +} + +/** + * 构造一条数据范围的 `content`——即 console 上选「与应用的可用范围一致」后保存 + * 的那个字符串。 + * + * 形态是**逐字节复刻 console** 的(拿 6 个已由人手在 console 配好的线上应用 + * 对照,5 个完全相同,第 6 个只有 `description` 里的权限显示名是飞书改名前的 + * 旧文案 —— 说明 description 纯展示、不参与语义): + * • `mode: 'part'` + 每个字段一条 `in` filter,filter value 是**再套一层 + * JSON 字符串**的 `[{mode:'availability_of_app',members:[],…}]` + * • `expression` 是 filter 的 1-based 序号用 ` and ` 连起来 + * • `description` 是给人看的摘要(console 的 `GC()` 拼的同款) + */ +export function buildPrivilegeAppAvailabilityContent(privilege: OpenPlatformPrivilege): string { + const filters = privilege.fields.map(field => ({ + field: field.id, + value: JSON.stringify([{ + mode: PRIVILEGE_RANGE_SAME_AS_APP_AVAILABILITY, + members: [] as string[], + departments: [] as string[], + groups: [] as string[], + }]), + operator: 'in', + })); + const description = + `${privilege.bizName} - ${privilege.name}\n` + + privilege.fields.map(field => `\t${field.name} 包含 与应用的可用范围一致 `).join('') + + '\n'; + return JSON.stringify({ + biz_id: privilege.bizId, + mode: 'part', + resource: privilege.resource, + filters, + expression: filters.map((_, index) => index + 1).join(' and '), + description, + }); +} + +/** + * 这条数据范围是否已经被**收敛过**(即不需要我们再动它)。 + * + * ⚠️ 不能简单判 `content` 非空。实测「一键创建智能体」模板建出来的应用,出生就 + * 带着 `{"mode":"all"}`(console 上显示为选中「全部」)——正是审批规则里要额外 + * 说明理由、视情况加签至 CEO-2 的那一档。按「有 content 就算配过」会把这个默认 + * 值当成用户的选择而跳过,于是新建 bot 永远带着「全部」提审,改动完全空转。 + * + * 所以判据是「**已收敛到 all 以外**」: + * • `mode:'all'`(全部)→ 需要我们收窄,视为未配置 + * • `mode:'part'` 但 `filters` 为空 → console 自己的 `XC()` 也不认这算配置好 + * (它要求 `mode==='all' || filters.length>0`),是个「看着配过、其实空」的 + * 中间态,同样视为未配置 + * • `mode:'part'` 且有 filters / 其它 → 人为或我们之前配过的具体范围,绝不覆盖 + * • 空 / `mode` 缺失 / `mode:'null'`(console 的「无」)→ 未配置 + */ +export function isPrivilegeRangeNarrowed(privilege: OpenPlatformPrivilege): boolean { + if (!privilege.content) return false; + let parsed: Record; + try { + parsed = asRecord(JSON.parse(privilege.content)); + } catch { + // content 存在但不是合法 JSON:不敢当成"配过了",也不敢覆盖——保守视为已配置, + // 交给人处理(覆盖一个读不懂的值风险更大)。 + return true; + } + const mode = parsed.mode; + if (typeof mode !== 'string' || mode === '' || mode === 'all' || mode === 'null') return false; + // 与 console 的 XC() 对齐:非 all 的 mode 必须真的带上筛选条件才算配置好。 + return Array.isArray(parsed.filters) && parsed.filters.length > 0; +} + +/** + * 挑出「必须配、但还没收敛」且能安全自动填的数据范围条目。 + * + * 只取 `isRequired`:console 自己的 gate(`jC()`)也只强制这一档,实测线上租户 + * 84 条 privilege 条目里 required 的只有 2 条(会议号查询会议信息 / 创建更新 + * 任务时可指定的人员范围)。已经收敛到具体范围的一律不碰——那可能是人手精心配 + * 过的,覆盖它比不配更糟;但模板默认的 `mode:'all'` **要**收窄(见 + * {@link isPrivilegeRangeNarrowed})。 + */ +export function selectPrivilegesNeedingAppAvailability( + state: OpenPlatformPrivilegeState, +): OpenPlatformPrivilege[] { + return state.privileges.filter(privilege => + privilege.isRequired + && !isPrivilegeRangeNarrowed(privilege) + && canFillPrivilegeWithAppAvailability(privilege)); +} + +/** + * 构造 `POST /developers/v1/privilege/update/` 的 payload。 + * + * ⚠️ 与 {@link buildSafeSettingPayload}(全量覆盖)**语义相反**:实测服务端按 + * `(bizId, resource)` **增量合并**——只传 1 条、改动它,同一应用里另一条已配好 + * 的数据范围逐字节不变。所以这里只传「本次要填的那几条」,不必像 console 前端 + * 那样把 84 条整包读回来再写。 + * + * 每条都在原始条目上浅拷贝改 `content`,其余字段(schema / privilegeStatus / + * isRequired…)原样回传,避免把服务端还会读的字段丢掉。 + */ +export function buildPrivilegeUpdatePayload(appId: string, privileges: OpenPlatformPrivilege[]) { + return { + clientId: appId, + privileges: privileges.map(privilege => ({ + ...privilege.raw, + content: buildPrivilegeAppAvailabilityContent(privilege), + })), + }; +} + +/** + * 读 `privilege/all` → 把「必须配但还没收敛」的数据范围写成「与应用的可用范围 + * 一致」。返回实际写了几条(0 = 没有待收窄的)。 + * + * 抽成共享函数是因为**两条路径都必须做**,且各自发的是不同的版本: + * • {@link createOpenPlatformAppWithClient} —— 模板建完立刻发第一版 + * • {@link automateOpenPlatformSetup} —— 权限自愈 / 补配时发下一版 + * 只做前者,存量 bot 永远不收窄;只做后者,新建 bot 的第一版仍带「全部」提审。 + * + * 调用方决定失败怎么处理(两处都是非致命,但一处 warn 一处进 result.warning)。 + */ +async function narrowRequiredPrivilegeRanges( + api: { postJson(path: string, body?: unknown): Promise }, + appId: string, +): Promise { + const state = extractOpenPlatformPrivileges(await api.postJson(`/developers/v1/privilege/all/${appId}`, {})); + const needFill = selectPrivilegesNeedingAppAvailability(state); + if (needFill.length === 0) return 0; + await api.postJson(`/developers/v1/privilege/update/${appId}`, buildPrivilegeUpdatePayload(appId, needFill)); + return needFill.length; +} + export function buildScopeUpdatePayload(appId: string, mapped: Pick) { return { clientId: appId, @@ -1142,6 +1385,25 @@ export async function automateOpenPlatformSetup( } } + // 权限点加进清单后,其中一部分还带一份「权限可访问的数据范围」要填(console + // 上是权限详情里的单选:全部 / 与应用的可用范围一致 / 按条件筛选)。这些权限 + // 的 scope level 是「需审核」,而字节租户的审批规则明写「非必要不申请全员数据, + // 如申请全员范围请提供充分的理由说明,视情况加签至 CEO-2」——留空提审时这一格 + // 是空的(privilegeStatus=Unset),且 schema 的 fallback_value 是 mode:'all', + // 等于把范围往「全部」那侧靠。所以这里主动收敛成「与应用的可用范围一致」: + // 语义上正是 botmux 需要的(bot 只对能看到它的人干活),也是审批规则鼓励的方向。 + // + // 非致命,与 scope 注册同档:数据范围没配好不该阻塞建 bot(它只影响后续审批 + // 快慢,不影响 bot 收发消息)。写入按 (bizId,resource) 增量合并,且只碰 + // 「isRequired 且当前为空」的条目——人手配过的范围一律不覆盖。 + let privilegeRangeCount = 0; + let privilegeRangeWarning: string | undefined; + try { + privilegeRangeCount = await narrowRequiredPrivilegeRanges({ postJson }, options.appId); + } catch (err: any) { + privilegeRangeWarning = safeErrorMessage(err); + } + // Web 创建的是普通企业自建应用(不是 SDK PersonalAgent),需要显式开启 // 机器人能力并把事件接收方式切到长连接。对已启用的 SDK/已有应用重复调用 // 是幂等的;这里设为致命步骤,因为缺任一项 daemon 都无法正常收消息。 @@ -1365,6 +1627,8 @@ export async function automateOpenPlatformSetup( scopeCount: importedScopeCount, skippedScopeCount, scopeWarning, + privilegeRangeCount, + privilegeRangeWarning, subscribedEventCount, eventWarning, missingVcEvents, @@ -1730,6 +1994,19 @@ export async function createOpenPlatformAppWithClient( await retryIdempotentOnTransientNetworkError(() => client.postJson(`/developers/v1/event/switch/${appId}`, { clientId: appId, eventMode: 4 })); // WebSocket + // 模板建出来的应用,「权限可访问的数据范围」出生就是 `mode:'all'`(console 上 + // 显示「全部」)——而这里紧接着就要发**第一个版本**。不先收窄,这一版就带着 + // 「全部」进审批:正是租户规则里要补充理由、视情况加签至 CEO-2 的那一档。 + // 后续 automateOpenPlatformSetup 也会做同一件事,但它发的是**下一个**版本, + // 救不回这一版,所以两处都必须做。 + // + // 非致命:数据范围只影响审批快慢,不影响应用能不能收发消息。这里正处在 + // 「应用已建成、还没发版」的窗口里,为它把整条创建链路判死(用户被丢进手动读 + // Secret 的恢复路径)代价明显更大。 + await narrowRequiredPrivilegeRanges(client, appId).catch((err: unknown) => { + console.warn(`权限数据范围自动收窄失败(不影响建 bot,可到开放平台手动选「与应用的可用范围一致」): ${safeErrorMessage(err)}`); + }); + // 复刻 console launcher「一键创建智能体」的最后一步:立刻用极简版本发布一次, // 让应用**上架启用**(tenantAppStatus 0→2)。这样返回的就是一个「已启用、可 // 收发消息」的应用——等价于旧 SDK registerApp 直接产出可用 PersonalAgent 的效果。 @@ -2284,6 +2561,46 @@ function collectScopeEntries(value: unknown, bucket: 'tenant' | 'user' | undefin } } +/** + * 从一条 privilege 里取出数据范围表单的字段定义。 + * + * 字段在响应里出现**两处**:解析好的 `schemaContent.selectionExpressionSchemaContent` + * 和原始 JSON 字符串 `schema`(内层 key 是首字母大写的 + * `SelectionExpressionSchemaContent`)。优先用前者,缺失时回退解析后者——两者 + * 在实测数据里内容一致,但结构化那份不保证一直在。 + */ +function extractPrivilegeStaffFields(record: Record): OpenPlatformPrivilegeField[] { + const structured = asRecord(asRecord(record.schemaContent).selectionExpressionSchemaContent); + let rawFields = Array.isArray(structured.fields) ? structured.fields : undefined; + if (!rawFields) { + const schemaText = pickString(record, ['schema']); + if (schemaText) { + try { + const parsed = asRecord(asRecord(JSON.parse(schemaText)).schema_content); + const inner = asRecord(parsed.SelectionExpressionSchemaContent); + if (Array.isArray(inner.fields)) rawFields = inner.fields; + } catch { + // schema 不是合法 JSON:当作没有字段,上层 canFill… 会因此跳过这条 + } + } + } + if (!rawFields) return []; + const fields: OpenPlatformPrivilegeField[] = []; + for (const entry of rawFields) { + const field = asRecord(entry); + const id = pickString(field, ['id']); + if (!id) continue; + const operators = Array.isArray(field.operators) ? field.operators : []; + fields.push({ + id, + name: pickString(field, ['name']) ?? '', + selectStaff: pickString(asRecord(field.data_source), ['type']) === 'select_staff', + supportsIn: operators.includes('in'), + }); + } + return fields; +} + function mapScopeIds(scopeNames: string[], catalog: OpenPlatformScopeEntry[], bucket: 'tenant' | 'user') { const ids: string[] = []; const missing: string[] = []; diff --git a/test/scope-optional-autofix.test.ts b/test/scope-optional-autofix.test.ts index 089a0c516..bc46a8ce2 100644 --- a/test/scope-optional-autofix.test.ts +++ b/test/scope-optional-autofix.test.ts @@ -70,7 +70,10 @@ describe('checkRequiredScopes — opt-in optional-scope auto-top-up', () => { }); describe('tryAutoFixScopes — silent / disableQrLogin plumbing', () => { - const region = fnRegion('async function tryAutoFixScopes(', 4200); + // ⚠️ 固定字符数窗口:函数体一变长,末尾的断言(DM 抬头文案)就会滑出窗口而 + // 失败——**不是**行为回归。加了「权限数据范围」那几行日志后实测需要 4448 字符, + // 这里留到 5200 给后续小改动一点余量。真正变动这段逻辑时看的是断言本身。 + const region = fnRegion('async function tryAutoFixScopes(', 5200); it('accepts the disableQrLogin + silent opts', () => { expect(region).toContain('opts?: { disableQrLogin?: boolean; silent?: boolean }'); diff --git a/test/setup-open-platform-automation.test.ts b/test/setup-open-platform-automation.test.ts index 991058c3b..a801e811a 100644 --- a/test/setup-open-platform-automation.test.ts +++ b/test/setup-open-platform-automation.test.ts @@ -14,17 +14,22 @@ import { BOTMUX_REDIRECT_URL, botmuxFeishuSessionFilePath, buildFeishuQrPayload, + buildPrivilegeAppAvailabilityContent, + buildPrivilegeUpdatePayload, buildSafeSettingPayload, buildScopeUpdatePayload, + canFillPrivilegeWithAppAvailability, collectBotmuxRedirectUrls, createFeishuOpenPlatformApp, createOpenPlatformApiClient, extractOpenPlatformCsrfToken, + extractOpenPlatformPrivileges, extractOpenPlatformRedirectUrls, extractOpenPlatformSessionIdentity, extractOpenPlatformScopeEntries, filterScopeManifest, getCookieHeader, + isPrivilegeRangeNarrowed, mapFeishuQrPollingStatus, mapManifestScopesToOpenPlatformIds, readDefaultScopeManifest, @@ -35,6 +40,7 @@ import { probeVcMeetingEventSubscription, readStoredCookiesFromSessionFile, safeErrorMessage, + selectPrivilegesNeedingAppAvailability, type StoredCookie, vcListenerEventGateError, writeRedirectWhitelist, @@ -381,6 +387,342 @@ describe('filterScopeManifest — 只申请缺失项,避免全量 manifest 过 }); }); +/** + * 「权限可访问的数据范围」自动填成「与应用的可用范围一致」。 + * + * 这是**独立于 scope/update 的第二条链路**:权限点进了清单,其中一部分还各带一份 + * 「这个权限能看到哪些数据」的表单。botmux 历史上完全没碰它,于是每次自动发版都 + * 带着「未配置」提审——而这些权限都是「需审核」档,租户审批规则明写申请全员数据 + * 范围要「视情况加签至 CEO-2」。 + * + * 下面的 fixture 是从**线上真实响应**(`privilege/all`)里摘出来的原样结构,不是 + * 手写的理想形状: + * • `vc/meeting.meetingid` —— 单个 select_staff 字段,isRequired,真实待配对象 + * • `security_and_compliance/dlp_execute_log` —— 同为 SelectionExpression + 内部 + * 组织,但字段里混了一个 `data_source.type==='url'` 的「工作地点」。这正是 + * `availability_of_app`(成员范围语义)塞不进去的形态,必须整条跳过。 + */ +describe('privilege 数据范围 —— 自动填「与应用的可用范围一致」', () => { + /** 线上 `privilege/all` 的真实条目(结构原样,只裁掉与判定无关的字段)。 */ + const VC_PRIVILEGE = { + bizId: 'vc', + resource: 'meeting.meetingid', + name: '会议号查询会议信息', + isRequired: true, + content: '', + privilegeStatus: 3, + schemaType: 1, + organizationType: 1, + schemaContent: { + selectionExpressionSchemaContent: { + fields: [{ + id: 'owner_scope', + name: '会议的归属者', + type: 'object', + multi: false, + operators: ['in'], + data_source: { type: 'select_staff', val: '' }, + }], + select_mode_options: ['all', 'part', 'null'], + fallback_value: { mode: 'all' }, + }, + }, + }; + /** 同样 needsDataRange,但含一个非选人字段(工作地点)——不可自动填。 */ + const DLP_PRIVILEGE = { + bizId: 'security_and_compliance', + resource: 'dlp_execute_log', + name: 'DLP执行日志', + isRequired: true, + content: '', + schemaType: 1, + organizationType: 1, + schemaContent: { + selectionExpressionSchemaContent: { + fields: [ + { id: 'member_range', name: '用户范围', operators: ['in', 'notIn'], data_source: { type: 'select_staff', val: '' } }, + { id: 'place', name: '工作地点', operators: ['in', 'notIn'], data_source: { type: 'url', val: '/oapi/…/places/query' } }, + ], + select_mode_options: ['all', 'part', 'null'], + fallback_value: { mode: 'all' }, + }, + }, + }; + const payloadOf = (privileges: any[], scopeBiz: any[] = [{ bizId: 'vc', bizName: '视频会议' }]) => + ({ code: 0, data: { privileges, scopeBiz } }); + + it('解析出条目、业务分类名与字段定义', () => { + const state = extractOpenPlatformPrivileges(payloadOf([VC_PRIVILEGE])); + expect(state.privileges).toHaveLength(1); + const [p] = state.privileges; + expect(p).toMatchObject({ + bizId: 'vc', resource: 'meeting.meetingid', name: '会议号查询会议信息', + bizName: '视频会议', isRequired: true, content: '', schemaType: 1, organizationType: 1, + }); + expect(p.fields).toEqual([{ id: 'owner_scope', name: '会议的归属者', selectStaff: true, supportsIn: true }]); + }); + + it('字段定义缺结构化那份时回退解析原始 schema 字符串', () => { + // 线上响应同时给 schemaContent(已解析)和 schema(JSON 字符串,内层 key 首字母 + // 大写)。前者不保证一直在,回退路径必须真能解析出字段——否则会静默降级成 + // 「没有字段」→ 整条跳过 → 又变回从不配置。 + const { schemaContent, ...withoutStructured } = VC_PRIVILEGE as any; + const state = extractOpenPlatformPrivileges(payloadOf([{ + ...withoutStructured, + schema: JSON.stringify({ + biz_id: 'vc', + schema_content: { + SelectionExpressionSchemaContent: schemaContent.selectionExpressionSchemaContent, + }, + }), + }])); + expect(state.privileges[0].fields) + .toEqual([{ id: 'owner_scope', name: '会议的归属者', selectStaff: true, supportsIn: true }]); + expect(canFillPrivilegeWithAppAvailability(state.privileges[0])).toBe(true); + }); + + it('只对「SelectionExpression + 内部组织 + 全字段可选人」放行', () => { + const fill = (p: any) => + canFillPrivilegeWithAppAvailability(extractOpenPlatformPrivileges(payloadOf([p])).privileges[0]); + expect(fill(VC_PRIVILEGE)).toBe(true); + // 混了非选人字段(工作地点)——availability_of_app 是成员范围语义,塞不进去。 + expect(fill(DLP_PRIVILEGE)).toBe(false); + // console 的两个判据各自都是必要条件。 + expect(fill({ ...VC_PRIVILEGE, schemaType: 3 })).toBe(false); + expect(fill({ ...VC_PRIVILEGE, organizationType: 2 })).toBe(false); + // 没有字段定义 → 不猜。 + expect(fill({ ...VC_PRIVILEGE, schemaContent: { selectionExpressionSchemaContent: { fields: [] } } })).toBe(false); + // 字段不支持「包含」(in) → 不猜。 + expect(fill({ + ...VC_PRIVILEGE, + schemaContent: { + selectionExpressionSchemaContent: { + fields: [{ id: 'owner_scope', name: 'x', operators: ['notIn'], data_source: { type: 'select_staff' } }], + }, + }, + })).toBe(false); + }); + + it('content 与 console 手工配置的结果逐字节相同', () => { + // 基准串取自**线上一个由人在 console 上手点「与应用的可用范围一致」的应用**, + // 原样粘过来。自己写的 builder 与它逐字节一致,才说明我们没在猜格式。 + const CONSOLE_WRITTEN = '{"biz_id":"vc","mode":"part","resource":"meeting.meetingid","filters":[{"field":"owner_scope","value":"[{\\"mode\\":\\"availability_of_app\\",\\"members\\":[],\\"departments\\":[],\\"groups\\":[]}]","operator":"in"}],"expression":"1","description":"视频会议 - 会议号查询会议信息\\n\\t会议的归属者 包含 与应用的可用范围一致 \\n"}'; + const [p] = extractOpenPlatformPrivileges(payloadOf([VC_PRIVILEGE])).privileges; + expect(buildPrivilegeAppAvailabilityContent(p)).toBe(CONSOLE_WRITTEN); + }); + + it('多字段时逐字段生成 filter,expression 用 1-based 序号 and 连接', () => { + const [p] = extractOpenPlatformPrivileges(payloadOf([{ + ...VC_PRIVILEGE, + schemaContent: { + selectionExpressionSchemaContent: { + fields: [ + { id: 'a', name: '甲', operators: ['in'], data_source: { type: 'select_staff' } }, + { id: 'b', name: '乙', operators: ['in'], data_source: { type: 'select_staff' } }, + ], + }, + }, + }])).privileges; + const parsed = JSON.parse(buildPrivilegeAppAvailabilityContent(p)); + expect(parsed.filters.map((f: any) => f.field)).toEqual(['a', 'b']); + expect(parsed.expression).toBe('1 and 2'); + // filter value 是**再套一层 JSON 字符串**的数组,不是对象——写错这层服务端不报错, + // 但 console 上会显示成未配置。 + expect(JSON.parse(parsed.filters[0].value)).toEqual([ + { mode: 'availability_of_app', members: [], departments: [], groups: [] }, + ]); + }); + + it('只挑「isRequired 且还没收敛」的,已收敛到具体范围的一律不覆盖', () => { + const state = extractOpenPlatformPrivileges(payloadOf([ + VC_PRIVILEGE, + // 非必填 → console 自己的 gate 也不强制,不碰。 + { ...VC_PRIVILEGE, resource: 'meeting.participant', isRequired: false }, + // 已经收敛到具体范围 → 可能是人手精心配的,覆盖它比不配更糟。 + { ...VC_PRIVILEGE, resource: 'vc.record', content: '{"mode":"part","filters":[{"field":"owner_scope","value":"[]","operator":"in"}]}' }, + // 必填但不可自动填 → 留给人手配。 + DLP_PRIVILEGE, + ])); + expect(selectPrivilegesNeedingAppAvailability(state).map(p => p.resource)) + .toEqual(['meeting.meetingid']); + }); + + /** + * 🔴 生产回归(live 实测发现):「一键创建智能体」模板建出来的应用,这两条数据 + * 范围**出生就带 `{"mode":"all"}`**(console 上显示选中「全部」)——正是审批规则里 + * 要补充理由、视情况加签至 CEO-2 的那一档。 + * + * 第一版守卫写的是「有 content 就算配过、不覆盖」(本意是别覆盖人手配的范围), + * 而模板塞的默认值刚好满足「有 content」⟹ 被当成用户的选择跳过, + * `privilegeRangeCount` 恒为 0,整个改动空转。下面两个 fixture 是**线上抓下来的 + * 原文**,不是构造的。 + */ + const TEMPLATE_DEFAULT_ALL_VC = { + ...VC_PRIVILEGE, + privilegeStatus: 2, + // 线上原文。`\n` 必须是 JSON 里的转义序列(`\\n` 在 JS 源码里),不是真换行—— + // 真换行会让这串不是合法 JSON,从而走进「读不懂 → 保守视为已配置」的分支, + // 把这个测试变成假绿。 + content: '{"biz_id":"vc","resource":"meeting.meetingid","mode":"all","description":"视频会议 - 会议号查询会议信息\\n\\t全部\\n"}', + }; + + it('模板默认的 mode:"all" 视为待收窄(不是"已配置")', () => { + const state = extractOpenPlatformPrivileges(payloadOf([TEMPLATE_DEFAULT_ALL_VC])); + expect(isPrivilegeRangeNarrowed(state.privileges[0])).toBe(false); + // 这一条是整个改动的成败所在:漏了它,新建 bot 永远带「全部」提审。 + expect(selectPrivilegesNeedingAppAvailability(state).map(p => p.resource)) + .toEqual(['meeting.meetingid']); + // 收窄后的目标形态:按条件筛选 + 与应用的可用范围一致。 + const rewritten = JSON.parse(buildPrivilegeAppAvailabilityContent(state.privileges[0])); + expect(rewritten.mode).toBe('part'); + expect(JSON.parse(rewritten.filters[0].value)[0].mode).toBe('availability_of_app'); + }); + + it('已收敛的判据是「mode 不是 all」,不是「content 非空」', () => { + const narrowed = (content: string) => + isPrivilegeRangeNarrowed(extractOpenPlatformPrivileges(payloadOf([{ ...VC_PRIVILEGE, content }])).privileges[0]); + expect(narrowed('')).toBe(false); // 未配置 + expect(narrowed('{"mode":"all"}')).toBe(false); // 模板默认「全部」 + expect(narrowed('{"mode":""}')).toBe(false); // 空 mode 同样不算收敛 + expect(narrowed('{"resource":"x"}')).toBe(false); // mode 整个缺失 + expect(narrowed('{"mode":"null"}')).toBe(false); // console 的「无」 + expect(narrowed('{"mode":"part","filters":[{"field":"owner_scope","value":"[]","operator":"in"}]}')).toBe(true); + // 我们自己写过的也算收敛 —— 重复跑权限自愈不该反复重写同一条。 + expect(narrowed(buildPrivilegeAppAvailabilityContent( + extractOpenPlatformPrivileges(payloadOf([VC_PRIVILEGE])).privileges[0]))).toBe(true); + // content 存在但读不懂 → 保守视为已配置:覆盖一个读不懂的值风险更大。 + expect(narrowed('{oops')).toBe(true); + }); + + /** + * 与 console 自己的「是否配置好」谓词 `XC()` 对齐:它要求 + * `mode === 'all' || (Array.isArray(filters) && filters.length > 0)`。 + * 也就是说 `mode:'part'` 但 filters 为空,在 console 眼里**不算配置好**(UI 上显示 + * 「暂未配置筛选条件」)。这是又一个「看着配过、其实是空的」中间态——放过它就是 + * 重犯 `mode:"all"` 那个空转 bug 的同类错误。 + */ + it('mode:part 但 filters 为空同样视为未收敛(对齐 console 的 XC())', () => { + const state = extractOpenPlatformPrivileges(payloadOf([{ + ...VC_PRIVILEGE, + content: '{"biz_id":"vc","mode":"part","resource":"meeting.meetingid","filters":[],"expression":""}', + }])); + expect(isPrivilegeRangeNarrowed(state.privileges[0])).toBe(false); + expect(selectPrivilegesNeedingAppAvailability(state).map(p => p.resource)).toEqual(['meeting.meetingid']); + }); + + it('写入 payload 只带本次要填的条目,并保留原始字段', () => { + const state = extractOpenPlatformPrivileges(payloadOf([VC_PRIVILEGE, DLP_PRIVILEGE])); + const payload = buildPrivilegeUpdatePayload('cli_x', selectPrivilegesNeedingAppAvailability(state)); + expect(payload.clientId).toBe('cli_x'); + // 增量合并语义(实测:服务端按 (bizId,resource) 合并)——不必回传全部条目。 + expect(payload.privileges).toHaveLength(1); + const [entry] = payload.privileges as any[]; + expect(entry.content).toBe(buildPrivilegeAppAvailabilityContent(state.privileges[0])); + // 原始字段原样回传:服务端还会读 schema / privilegeStatus 等,丢了它们就等于 + // 拿一个残缺条目去覆盖。 + expect(entry).toMatchObject({ + bizId: 'vc', resource: 'meeting.meetingid', isRequired: true, privilegeStatus: 3, + schemaType: 1, organizationType: 1, + }); + expect(entry.schemaContent).toEqual(VC_PRIVILEGE.schemaContent); + }); + + it('没有待填的条目时一个写请求都不发', () => { + const state = extractOpenPlatformPrivileges(payloadOf([DLP_PRIVILEGE])); + expect(selectPrivilegesNeedingAppAvailability(state)).toEqual([]); + }); + + it('响应结构异常/为空时安全降级为「没有条目」', () => { + expect(extractOpenPlatformPrivileges(null).privileges).toEqual([]); + expect(extractOpenPlatformPrivileges({ code: 0 }).privileges).toEqual([]); + expect(extractOpenPlatformPrivileges({ data: { privileges: 'nope' } }).privileges).toEqual([]); + // 缺 bizId 就拼不出合并键,写回去也定位不到条目 → 丢弃而不是硬塞。 + expect(extractOpenPlatformPrivileges(payloadOf([{ resource: 'x', isRequired: true }])).privileges).toEqual([]); + // schema 不是合法 JSON → 当作没有字段,由 canFill… 跳过,不抛。 + const bad = extractOpenPlatformPrivileges(payloadOf([{ ...VC_PRIVILEGE, schemaContent: undefined, schema: '{oops' }])); + expect(bad.privileges[0].fields).toEqual([]); + expect(canFillPrivilegeWithAppAvailability(bad.privileges[0])).toBe(false); + }); + + /** + * 上面全是纯函数。这里跑**真实的 automation**,验证接线本身:请求真的发出去了、 + * 落在 `app_version/create` 之前(否则本次发版仍带「未配置」提审,等于没修)、 + * 失败时不阻塞建 bot。纯函数全绿但没接上线,是这类改动最典型的空转。 + */ + it('automation 真的发出 privilege/update,且在发版之前', async () => { + const run = async (label: string, opts: { privilegeAll?: unknown; failRead?: boolean; failWrite?: boolean }) => { + const dir = mkdtempSync(join(tmpdir(), `privrange-${label}-`)); + const sessionFile = join(dir, 'feishu-session.json'); + writeStoredCookiesToSessionFile(sessionFile, [cookie()]); + const sub = openPlatformSubscriptionMock('cli_p'); + const calls: string[] = []; + const writes: any[] = []; + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + const href = String(url); + if (href === 'https://ask.feishu.cn/') return new Response('ask home', { status: 200 }); + if (href.endsWith('/app/cli_p/auth')) return new Response('', { status: 200 }); + const path = href.replace(/^https:\/\/[^/]+/, ''); + if (path.startsWith('/developers/')) calls.push(path); + if (path.includes('/scope/all/')) { + return Response.json({ code: 0, data: { appScopeList: [{ id: 't1', name: 'im:message' }], userScopeList: [] } }); + } + if (path.includes('/privilege/all/')) { + if (opts.failRead) return Response.json({ code: 1, msg: 'privilege read denied' }); + return Response.json(opts.privilegeAll ?? payloadOf([VC_PRIVILEGE])); + } + if (path.includes('/privilege/update/')) { + if (opts.failWrite) return Response.json({ code: 1, msg: 'privilege write rejected' }); + writes.push(JSON.parse(String(init?.body))); + return Response.json({ code: 0 }); + } + if (path.includes('/app_version/list/')) return Response.json({ code: 0, data: { versions: [{ appVersion: '1.0.0' }] } }); + if (path.includes('/app_version/create/')) return Response.json({ code: 0, data: { versionId: 'v1' } }); + return sub.handle(href, init) ?? Response.json({ code: 0 }); + }) as typeof fetch; + const r = await automateOpenPlatformSetup({ + appId: 'cli_p', sessionFilePath: sessionFile, fetchImpl, disableQrLogin: true, + scopeManifest: { scopes: { tenant: ['im:message'], user: [] } }, + }); + expect(r.ok, `${label}: ok=false reason=${(r as any).reason}`).toBe(true); + if (!r.ok) throw new Error('unreachable'); + return { calls, writes, count: r.privilegeRangeCount, warning: r.privilegeRangeWarning }; + }; + + // ① 有待配的 → 写请求发出,内容是「与应用的可用范围一致」 + const applied = await run('applied', {}); + expect(applied.count).toBe(1); + expect(applied.warning).toBeUndefined(); + expect(applied.writes).toHaveLength(1); + expect(applied.writes[0].clientId).toBe('cli_p'); + expect(JSON.parse(applied.writes[0].privileges[0].content).filters[0].value) + .toContain('availability_of_app'); + // 顺序判据:数据范围必须在**本次发版之前**写完,否则这一版仍带「未配置」提审。 + const writeAt = applied.calls.findIndex(p => p.includes('/privilege/update/')); + const versionAt = applied.calls.findIndex(p => p.includes('/app_version/create/')); + expect(writeAt).toBeGreaterThanOrEqual(0); + expect(versionAt).toBeGreaterThanOrEqual(0); + expect(writeAt).toBeLessThan(versionAt); + // 也必须在 scope/update 之后:权限点还没进清单时,它带的数据范围条目也还不在。 + expect(applied.calls.findIndex(p => p.includes('/scope/update/'))).toBeLessThan(writeAt); + + // ② 没有待配的 → 一个写请求都不发,且 count=0 不带 warning(调用方据此区分成因) + const noop = await run('noop', { privilegeAll: payloadOf([DLP_PRIVILEGE]) }); + expect(noop.writes).toEqual([]); + expect(noop.calls.some(p => p.includes('/privilege/update/'))).toBe(false); + expect({ count: noop.count, warned: Boolean(noop.warning) }).toEqual({ count: 0, warned: false }); + + // ③ 读失败 / ④ 写失败 → 非致命:ok:true 照常发版建 bot,但 count=0 且**带 + // warning**,与②明确可区分(不带 warning 会被读成「本来就没有待配的」)。 + for (const [label, opts] of [['read-fail', { failRead: true }], ['write-fail', { failWrite: true }]] as const) { + const failed = await run(label, opts); + expect({ label, count: failed.count, warned: Boolean(failed.warning) }) + .toEqual({ label, count: 0, warned: true }); + expect(failed.calls.some(p => p.includes('/app_version/create/')), `${label}: 仍应发版`).toBe(true); + } + }); +}); + + describe('redirect 白名单读→合并→写', () => { /** postJson 桩:读接口返回 `read`(或抛错),写接口按 `writeResults` 顺序成功/失败。 */ @@ -991,6 +1333,9 @@ describe('createFeishuOpenPlatformApp', () => { '/developers/v1/manifest/upsert_by_template', '/developers/v1/robot/switch/cli_created', '/developers/v1/event/switch/cli_created', + // 模板建出来的应用数据范围默认是 mode:'all'(「全部」),必须在**这一版发布之前** + // 收窄——这个 mock 的 privilege/all 返回空,所以只有读、没有 privilege/update。 + '/developers/v1/privilege/all/cli_created', '/developers/v1/app_version/create/cli_created', '/developers/v1/publish/commit/cli_created/v-enable', '/developers/v1/secret/cli_created', @@ -1044,12 +1389,124 @@ describe('createFeishuOpenPlatformApp', () => { '/developers/v1/app/create', '/developers/v1/robot/switch/cli_fallback', '/developers/v1/event/switch/cli_fallback', + // 回退路径(裸自建应用)同样在发版前收窄数据范围。 + '/developers/v1/privilege/all/cli_fallback', '/developers/v1/app_version/create/cli_fallback', '/developers/v1/publish/commit/cli_fallback/v-enable', '/developers/v1/secret/cli_fallback', ]); }); + /** + * 🔴 生产回归(live 建 bot 实测发现):模板建出来的应用,数据范围出生就是 + * `mode:'all'`(「全部」),而**紧接着就发第一个版本**。只在 + * `automateOpenPlatformSetup` 里收窄救不回这一版(它发的是下一版),所以创建 + * 路径必须自己做一次。上面的顺序断言只证明「读了」,这里证明「**真写了**、且 + * 写在发版之前、内容是与应用的可用范围一致」。 + */ + it('模板默认的「全部」在第一个版本发布前就被收窄', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-open-platform-narrow-')); + const sessionFile = join(dir, 'feishu-session.json'); + writeStoredCookiesToSessionFile(sessionFile, [cookie()]); + const calls: string[] = []; + let written: any; + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + const href = String(url); + if (href === 'https://ask.feishu.cn/') return new Response('ask home', { status: 200 }); + if (href === 'https://open.feishu.cn/app') return new Response(openPlatformPage(), { status: 200 }); + const path = new URL(href).pathname; + calls.push(path); + if (path === '/developers/v1/app/upload/image') { + return Response.json({ code: 0, data: { url: 'https://cdn.example/botmux.png' } }); + } + if (path === '/developers/v1/manifest/upsert_by_template') { + return Response.json({ code: 0, data: { clientID: 'cli_narrow' } }); + } + if (path === '/developers/v1/privilege/all/cli_narrow') { + // 线上模板建出来的真实形态:isRequired 且 mode:'all'。 + return Response.json({ + code: 0, + data: { + scopeBiz: [{ bizId: 'vc', bizName: '视频会议' }], + privileges: [{ + bizId: 'vc', resource: 'meeting.meetingid', name: '会议号查询会议信息', + isRequired: true, privilegeStatus: 2, schemaType: 1, organizationType: 1, + content: '{"biz_id":"vc","resource":"meeting.meetingid","mode":"all","description":"视频会议 - 会议号查询会议信息\\n\\t全部\\n"}', + schemaContent: { + selectionExpressionSchemaContent: { + fields: [{ id: 'owner_scope', name: '会议的归属者', operators: ['in'], data_source: { type: 'select_staff', val: '' } }], + select_mode_options: ['all', 'part', 'null'], + }, + }, + }], + }, + }); + } + if (path === '/developers/v1/privilege/update/cli_narrow') { + written = JSON.parse(String(init?.body)); + return Response.json({ code: 0 }); + } + if (path === '/developers/v1/app_version/create/cli_narrow') { + return Response.json({ code: 0, data: { versionId: 'v-enable' } }); + } + if (path === '/developers/v1/secret/cli_narrow') { + return Response.json({ code: 0, data: { secret: 'narrow-secret' } }); + } + return Response.json({ code: 0 }); + }) as typeof fetch; + + const result = await createFeishuOpenPlatformApp({ + name: 'botmux-narrow', sessionFilePath: sessionFile, disableBytedcliFallback: true, fetchImpl, + }); + expect(result).toMatchObject({ ok: true, appId: 'cli_narrow' }); + + // 真的发出了写请求,且内容是「按条件筛选 + 与应用的可用范围一致」 + expect(written?.clientId).toBe('cli_narrow'); + const content = JSON.parse(written.privileges[0].content); + expect(content.mode).toBe('part'); + expect(JSON.parse(content.filters[0].value)[0].mode).toBe('availability_of_app'); + + // 顺序:收窄必须在**这一版**发布之前,否则第一版仍带「全部」进审批。 + const narrowAt = calls.indexOf('/developers/v1/privilege/update/cli_narrow'); + const versionAt = calls.indexOf('/developers/v1/app_version/create/cli_narrow'); + expect(narrowAt).toBeGreaterThanOrEqual(0); + expect(versionAt).toBeGreaterThan(narrowAt); + }); + + it('数据范围收窄失败不影响建 bot(非致命)', async () => { + // 这里正处在「应用已建成、还没发版」的窗口:为一个只影响审批快慢的步骤把整条 + // 创建链路判死,会把用户丢进手动读 Secret 的恢复路径,代价明显更大。 + const dir = mkdtempSync(join(tmpdir(), 'botmux-open-platform-narrowfail-')); + const sessionFile = join(dir, 'feishu-session.json'); + writeStoredCookiesToSessionFile(sessionFile, [cookie()]); + const fetchImpl = (async (url: string | URL | Request) => { + const href = String(url); + if (href === 'https://ask.feishu.cn/') return new Response('ask home', { status: 200 }); + if (href === 'https://open.feishu.cn/app') return new Response(openPlatformPage(), { status: 200 }); + const path = new URL(href).pathname; + if (path === '/developers/v1/app/upload/image') { + return Response.json({ code: 0, data: { url: 'https://cdn.example/botmux.png' } }); + } + if (path === '/developers/v1/manifest/upsert_by_template') { + return Response.json({ code: 0, data: { clientID: 'cli_nf' } }); + } + if (path === '/developers/v1/privilege/all/cli_nf') { + return Response.json({ code: 1, msg: 'privilege read denied' }); + } + if (path === '/developers/v1/app_version/create/cli_nf') { + return Response.json({ code: 0, data: { versionId: 'v-enable' } }); + } + if (path === '/developers/v1/secret/cli_nf') { + return Response.json({ code: 0, data: { secret: 'nf-secret' } }); + } + return Response.json({ code: 0 }); + }) as typeof fetch; + + await expect(createFeishuOpenPlatformApp({ + name: 'botmux-nf', sessionFilePath: sessionFile, disableBytedcliFallback: true, fetchImpl, + })).resolves.toMatchObject({ ok: true, appId: 'cli_nf', appSecret: 'nf-secret' }); + }); + function outcomeUnknownFetchImpl(calls: string[], templateResponse: () => Response | Promise) { return (async (url: string | URL | Request) => { const href = String(url); @@ -1480,6 +1937,9 @@ describe('automateOpenPlatformSetup', () => { '/developers/v1/safe_setting/update/cli_x', '/developers/v1/scope/all/cli_x', '/developers/v1/scope/update/cli_x', + // 权限点进清单后紧接着读它带的「数据范围」条目(这个 mock 没有待配条目, + // 所以只有读、没有 privilege/update)。 + '/developers/v1/privilege/all/cli_x', '/developers/v1/robot/switch/cli_x', '/developers/v1/event/switch/cli_x', '/developers/v1/event/cli_x', @@ -1557,6 +2017,9 @@ describe('automateOpenPlatformSetup', () => { '/developers/v1/safe_setting/update/cli_x', '/developers/v1/scope/all/cli_x', '/developers/v1/scope/update/cli_x', + // 权限点进清单后紧接着读它带的「数据范围」条目(这个 mock 没有待配条目, + // 所以只有读、没有 privilege/update)。 + '/developers/v1/privilege/all/cli_x', '/developers/v1/robot/switch/cli_x', '/developers/v1/event/switch/cli_x', '/developers/v1/event/cli_x', diff --git a/test/setup-pickers.test.ts b/test/setup-pickers.test.ts index 1fca9a62a..292ded27d 100644 --- a/test/setup-pickers.test.ts +++ b/test/setup-pickers.test.ts @@ -166,6 +166,7 @@ describe('createOpenPlatformAppWithClient', () => { { code: 0, data: { ClientID: 'cli_new' } }, // upsert_by_template { code: 0 }, // robot/switch { code: 0 }, // event/switch + { code: 0, data: { privileges: [], scopeBiz: [] } }, // privilege/all(数据范围收窄,无待收窄项) { code: 0, data: { versionId: 'v-init' } }, // app_version/create(启用发布) { code: 0 }, // publish/commit { code: 0, data: { secret: 'new-secret' } }, // secret @@ -181,6 +182,9 @@ describe('createOpenPlatformAppWithClient', () => { '/developers/v1/manifest/upsert_by_template', '/developers/v1/robot/switch/cli_new', '/developers/v1/event/switch/cli_new', + // 模板建出的应用数据范围默认是 mode:'all'(「全部」),必须在**这一版发布之前** + // 收窄,否则第一个版本仍带「全部」进审批。 + '/developers/v1/privilege/all/cli_new', '/developers/v1/app_version/create/cli_new', '/developers/v1/publish/commit/cli_new/v-init', '/developers/v1/secret/cli_new', @@ -199,15 +203,18 @@ describe('createOpenPlatformAppWithClient', () => { }); expect(client.calls[2].body).toEqual({ clientId: 'cli_new', enable: true }); expect(client.calls[3].body).toEqual({ clientId: 'cli_new', eventMode: 4 }); - // 启用发布用极简版本 payload,可见成员含创建者(否则发布后不自动上架启用) - expect(client.calls[4].body).toMatchObject({ + // 启用发布用极简版本 payload,可见成员含创建者(否则发布后不自动上架启用)。 + // ⚠️ 按**路径**取而不是按下标:这条链路中间插过步骤(数据范围收窄),写死下标会让 + // 任何后续插入都变成一堆看不出所以然的失败。 + const bodyOf = (needle: string) => client.calls.find(call => call.path.includes(needle))?.body as any; + expect(bodyOf('/app_version/create/')).toMatchObject({ appVersion: '1.0.0', visibleSuggest: { members: ['u_creator'], isAll: 0 }, pcDefaultAbility: 'bot', mobileDefaultAbility: 'bot', }); - expect(client.calls[4].body).not.toHaveProperty('applyReasonConfig'); - expect(client.calls[5].body).toEqual({ clientId: 'cli_new' }); + expect(bodyOf('/app_version/create/')).not.toHaveProperty('applyReasonConfig'); + expect(bodyOf('/publish/commit/')).toEqual({ clientId: 'cli_new' }); }); it('fails closed (with appId) when the enabling publish commit fails — no silent orphan', async () => { @@ -216,6 +223,7 @@ describe('createOpenPlatformAppWithClient', () => { { code: 0, data: { ClientID: 'cli_commit_fail' } }, { code: 0 }, { code: 0 }, + { code: 0, data: { privileges: [], scopeBiz: [] } }, // privilege/all(数据范围收窄) { code: 0, data: { versionId: 'v-init' } }, // 版本创建成功 { code: 1, msg: 'publish commit rejected' }, // commit 失败 → 抛 ]); @@ -227,6 +235,7 @@ describe('createOpenPlatformAppWithClient', () => { '/developers/v1/manifest/upsert_by_template', '/developers/v1/robot/switch/cli_commit_fail', '/developers/v1/event/switch/cli_commit_fail', + '/developers/v1/privilege/all/cli_commit_fail', '/developers/v1/app_version/create/cli_commit_fail', '/developers/v1/publish/commit/cli_commit_fail/v-init', ]); @@ -238,6 +247,7 @@ describe('createOpenPlatformAppWithClient', () => { { code: 0, data: { ClientID: 'cli_noverid' } }, { code: 0 }, { code: 0 }, + { code: 0, data: { privileges: [], scopeBiz: [] } }, // privilege/all(数据范围收窄) { code: 0, data: {} }, // code=0 但没 versionId → 可能留下未发布草稿 ]); await expect(createOpenPlatformAppWithClient(client, { name: 'botmux-nv', creatorUserId: 'u_creator' })) @@ -253,6 +263,7 @@ describe('createOpenPlatformAppWithClient', () => { { code: 0, data: { ClientID: 'cli_orphan_guard' } }, { code: 0 }, { code: 0 }, + { code: 0, data: { privileges: [], scopeBiz: [] } }, // privilege/all(数据范围收窄) { code: 0, data: { versionId: 'v-init' } }, { code: 0 }, { code: 0, data: {} }, // secret 缺失