From 5f76faa3836e0633da605f6327c2a00ec466f4b9 Mon Sep 17 00:00:00 2001 From: "tongzhou.li" Date: Thu, 2 Jul 2026 01:02:34 +0800 Subject: [PATCH 1/9] Add Zhizhangyi MBS service adapter Signed-off-by: tongzhou.li --- services/zhizhangyi__mbs/README.md | 193 ++++++++++ .../zhizhangyi__mbs/bin/zhizhangyi-mbs.js | 7 + services/zhizhangyi__mbs/config.schema.json | 26 ++ services/zhizhangyi__mbs/package.json | 20 + .../proto/zhizhangyi_mbs.proto | 353 +++++++++++++++++ services/zhizhangyi__mbs/secret.schema.json | 19 + services/zhizhangyi__mbs/service.json | 69 ++++ services/zhizhangyi__mbs/src/service.js | 7 + .../zhizhangyi__mbs/src/zhizhangyi-mbs.js | 280 ++++++++++++++ .../test/zhizhangyi-mbs.test.js | 364 ++++++++++++++++++ .../zhizhangyi__mbs/testdata/test_4apis.mjs | 130 +++++++ .../testdata/test_getusers.mjs | 104 +++++ .../testdata/test_scenarios.mjs | 101 +++++ 13 files changed, 1673 insertions(+) create mode 100644 services/zhizhangyi__mbs/README.md create mode 100755 services/zhizhangyi__mbs/bin/zhizhangyi-mbs.js create mode 100644 services/zhizhangyi__mbs/config.schema.json create mode 100644 services/zhizhangyi__mbs/package.json create mode 100644 services/zhizhangyi__mbs/proto/zhizhangyi_mbs.proto create mode 100644 services/zhizhangyi__mbs/secret.schema.json create mode 100644 services/zhizhangyi__mbs/service.json create mode 100644 services/zhizhangyi__mbs/src/service.js create mode 100644 services/zhizhangyi__mbs/src/zhizhangyi-mbs.js create mode 100644 services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js create mode 100644 services/zhizhangyi__mbs/testdata/test_4apis.mjs create mode 100644 services/zhizhangyi__mbs/testdata/test_getusers.mjs create mode 100644 services/zhizhangyi__mbs/testdata/test_scenarios.mjs diff --git a/services/zhizhangyi__mbs/README.md b/services/zhizhangyi__mbs/README.md new file mode 100644 index 00000000..546ffd7d --- /dev/null +++ b/services/zhizhangyi__mbs/README.md @@ -0,0 +1,193 @@ +# Zhizhangyi MBS + +This service package exposes Zhizhangyi MBS (Mobile Security Management Platform) user management APIs through OctoBus. + +The package is scoped to MBS user operations: listing users, reading user details, checking login names, creating and updating users, deleting users, enabling or disabling users, updating passwords, forcing users offline, and importing users. + +## Configuration + +Configure the target MBS endpoint in the service config: + +```json +{ + "endpoint": "https://mbs.example.com:9074", + "skipTlsVerify": false, + "timeoutMs": 30000 +} +``` + +`endpoint` is the MBS REST API base URL. Use a hostname or sanitized placeholder in shared examples. Do not commit real internal IP addresses. + +Configure credentials in the service secret: + +```json +{ + "appkey": "", + "secretkey": "", + "orgCode": "" +} +``` + +Do not commit real `appkey`, `secretkey`, `orgCode`, password ciphertext, or generated `sign` values. + +## Authentication + +MBS signs requests as: + +```text +MD5(appkey + param1 + param2 + ... + secretkey) +``` + +The adapter computes signatures when `sign` is not provided. Password fields are forwarded as caller-provided MBS 3DES-encrypted strings; this package does not derive or perform password encryption. + +## Methods + +All methods are exposed under the `zhizhangyi.mbs.UserManagement` service. + +| Method | Purpose | Key request fields | +| --- | --- | --- | +| `GetUsers` | List users with pagination, sorting, and filters. | `index`, `size`, `orderCode`, `orderType`, `condition.deptId` required, optional `condition.keyWord`, `condition.state`, `condition.isMdm`. | +| `AddUser` | Create a user. | `userName`, `loginName`, `deptId`, `password` required; optional contact fields, `userSource`, `isMdm`, `state`, `weight`, `attrs`. | +| `UpdUser` | Update a user. | `userId`, `userName`, `deptId` required; optional `loginName`, contact fields, `isMdm`, `weight`, `attrs`. | +| `DetailUser` | Get user detail by ID. | `userId` required. | +| `DelUsers` | Delete users by ID list or by condition. | `type=0` with `userIds`, or `type=1` with `condition`. | +| `StateUsers` | Enable or disable users by ID list or condition. | `state` required; `type=0` with `userIds`, or `type=1` with `condition`. | +| `CheckLoginName` | Check login name availability. | `loginName` required. | +| `GetUserByPhone` | Find users by phone number. | `phone` required. | +| `UpdUserPwd` | Update a password using v1 admin mode or v2 self-service mode. | `version` must be `v1` or `v2`; v1 requires `userId` and `password`; v2 requires `loginName` and `newPwd`, optional `oldPwd`. | +| `ForceOffline` | Force a user offline. | `userId` required. | +| `ImportUser` | Import users from an uploaded file. | `fileId` required, optional `lang`. | + +## Connect RPC Examples + +Replace `mbs-capset` and `mbs-instance` with your OctoBus capset and instance IDs. The examples use sanitized hostnames and placeholder secrets only. + +### GetUsers + +```http +POST http://127.0.0.1:9000/capsets/mbs-capset/connect/mbs-instance/zhizhangyi.mbs.UserManagement/GetUsers +Content-Type: application/json + +{ + "index": 0, + "size": 20, + "orderCode": 0, + "orderType": 1, + "condition": { + "deptId": "1", + "keyWord": "", + "state": 0, + "isMdm": 0 + } +} +``` + +`condition.deptId` is required. Valid falsy filter values such as `state: 0` and `isMdm: 0` are preserved. + +### AddUser + +```http +POST http://127.0.0.1:9000/capsets/mbs-capset/connect/mbs-instance/zhizhangyi.mbs.UserManagement/AddUser +Content-Type: application/json + +{ + "userName": "Example User", + "loginName": "example.user", + "deptId": "1", + "password": "<3des-encrypted-password>", + "phoneNumber": "13800000000", + "email": "user@example.com", + "userSource": 0, + "state": 1, + "isMdm": 0 +} +``` + +`password` must already be encrypted in the format required by MBS. Empty numeric fields such as `state`, `isMdm`, and `weight` are treated as omitted instead of being converted to `0`. + +### UpdUser + +```http +POST http://127.0.0.1:9000/capsets/mbs-capset/connect/mbs-instance/zhizhangyi.mbs.UserManagement/UpdUser +Content-Type: application/json + +{ + "userId": "", + "userName": "Example User", + "deptId": "1", + "email": "user@example.com" +} +``` + +`userId`, `userName`, and `deptId` are required. + +### DelUsers + +```http +POST http://127.0.0.1:9000/capsets/mbs-capset/connect/mbs-instance/zhizhangyi.mbs.UserManagement/DelUsers +Content-Type: application/json + +{ + "type": 0, + "userIds": ["", ""] +} +``` + +For condition mode, set `type` to `1` and provide `condition`: + +```json +{ + "type": 1, + "condition": { + "deptId": "1", + "status": 0, + "isMdm": 0 + } +} +``` + +### StateUsers + +```http +POST http://127.0.0.1:9000/capsets/mbs-capset/connect/mbs-instance/zhizhangyi.mbs.UserManagement/StateUsers +Content-Type: application/json + +{ + "type": 0, + "state": "1", + "userIds": [""] +} +``` + +`state` is required and is not defaulted. Use `"1"` to enable and `"0"` to disable according to the MBS API semantics. + +### UpdUserPwd + +```http +POST http://127.0.0.1:9000/capsets/mbs-capset/connect/mbs-instance/zhizhangyi.mbs.UserManagement/UpdUserPwd +Content-Type: application/json + +{ + "version": "v2", + "loginName": "example.user", + "oldPwd": "<3des-encrypted-old-password>", + "newPwd": "<3des-encrypted-new-password>" +} +``` + +`version` is restricted to `v1` or `v2`. Invalid values are rejected before constructing the upstream path. + +## Error Handling + +HTTP errors from MBS are mapped to gRPC errors. Upstream HTTP response bodies are truncated before being included in error messages, so callers do not receive full upstream debug output or sensitive error details. + +## Local Testing + +Run service tests with: + +```bash +node --test services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js +npm --prefix services test -- --service-dir zhizhangyi__mbs +``` + +The test files use mocked HTTP responses and sanitized example values. diff --git a/services/zhizhangyi__mbs/bin/zhizhangyi-mbs.js b/services/zhizhangyi__mbs/bin/zhizhangyi-mbs.js new file mode 100755 index 00000000..508272f0 --- /dev/null +++ b/services/zhizhangyi__mbs/bin/zhizhangyi-mbs.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../src/service.js"; + +runServiceMain(service); diff --git a/services/zhizhangyi__mbs/config.schema.json b/services/zhizhangyi__mbs/config.schema.json new file mode 100644 index 00000000..ad7646c5 --- /dev/null +++ b/services/zhizhangyi__mbs/config.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "endpoint": { + "type": "string", + "description": "MBS REST API base URL. Example: https://mbs.example.com:9074" + }, + "baseUrl": { + "type": "string", + "description": "Legacy alias for endpoint." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification. Enable only when the target MBS endpoint uses a trusted self-signed certificate." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 30000, + "description": "HTTP timeout in milliseconds." + } + } +} diff --git a/services/zhizhangyi__mbs/package.json b/services/zhizhangyi__mbs/package.json new file mode 100644 index 00000000..b9df352f --- /dev/null +++ b/services/zhizhangyi__mbs/package.json @@ -0,0 +1,20 @@ +{ + "name": "zhizhangyi-mbs", + "version": "0.0.0", + "private": true, + "type": "module", + "files": [ + "bin/zhizhangyi-mbs.js", + "config.schema.json", + "secret.schema.json", + "service.json", + "proto", + "src" + ], + "bin": { + "zhizhangyi-mbs": "bin/zhizhangyi-mbs.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/zhizhangyi__mbs/proto/zhizhangyi_mbs.proto b/services/zhizhangyi__mbs/proto/zhizhangyi_mbs.proto new file mode 100644 index 00000000..454de278 --- /dev/null +++ b/services/zhizhangyi__mbs/proto/zhizhangyi_mbs.proto @@ -0,0 +1,353 @@ +syntax = "proto3"; + +package zhizhangyi.mbs; + +import "google/protobuf/struct.proto"; + +// ============================================================================= +// MBS (Mobile Security Management Platform) User Management API +// Vendor: 北京指掌易科技有限公司 +// Base URL: https://{host}:9074/uusafe/mos/thirdaccess/rest/opt/{version}/{operator} +// Auth: MD5(appkey + param1 + param2 + ... + secretkey), 拼接不含 "+" +// ============================================================================= + +service UserManagement { + // 6.2.1 用户列表(分页/筛选) + rpc GetUsers(GetUsersRequest) returns (GetUsersResponse); + + // 6.2.2 新增用户 + rpc AddUser(AddUserRequest) returns (AddUserResponse); + + // 6.2.3 编辑用户 + rpc UpdUser(UpdUserRequest) returns (UpdUserResponse); + + // 6.2.4 用户详情 + rpc DetailUser(DetailUserRequest) returns (DetailUserResponse); + + // 6.2.5 删除用户 + rpc DelUsers(DelUsersRequest) returns (DelUsersResponse); + + // 6.2.6 启停用户 + rpc StateUsers(StateUsersRequest) returns (StateUsersResponse); + + // 6.2.7 账号校验 + rpc CheckLoginName(CheckLoginNameRequest) returns (CheckLoginNameResponse); + + // 6.2.8 根据手机号获取用户 + rpc GetUserByPhone(GetUserByPhoneRequest) returns (GetUserByPhoneResponse); + + // 6.2.9 / 6.2.10 修改密码 + rpc UpdUserPwd(UpdUserPwdRequest) returns (UpdUserPwdResponse); + + // 6.2.11 强制下线 + rpc ForceOffline(ForceOfflineRequest) returns (ForceOfflineResponse); + + // 6.2.12 导入用户 + rpc ImportUser(ImportUserRequest) returns (ImportUserResponse); +} + +// ============================================================================= +// Common / Shared Messages +// ============================================================================= + +// 扩展字段键值对 +message ExAttrVo { + string attr_key = 1; + string attr_value = 2; +} + +// 查询条件 map +message GetUsersCondition { + string key_word = 1; // 关键字(用户名/姓名/手机号/工号/邮箱) + string state = 2; // 激活状态 -1未激活 0停用 1激活(逗号分隔多选) + string is_mdm = 3; // 是否开启设备管控 0未开启 1开启(逗号分隔多选) + string dept_id = 4; // 部门ID +} + +// 删除/启停条件 map +message BatchCondition { + string key_word = 1; + string status = 2; + string is_mdm = 3; + string dept_id = 4; +} + +// 用户基本信息(列表项) +message UserInfo { + string user_id = 1; + string user_name = 2; + string login_name = 3; + string phone_number = 4; + string email = 5; + string employee_number = 6; + string dept_id = 7; + string dept_name = 8; + int32 device_count = 9; + int32 is_mdm = 10; + int32 state = 11; + int32 is_admin = 12; + int32 user_source = 13; + int32 status = 14; + int32 weight = 15; + repeated ExAttrVo attrs = 16; + string dept_full_id = 17; + string dept_full_path = 18; + string job = 19; + string mobile = 20; + string address = 21; + string organization = 22; +} + +// 用户详情 +message UserDetail { + string user_id = 1; + string user_name = 2; + string login_name = 3; + string dept_id = 4; + string dept_name = 5; + string phone_number = 6; + string job = 7; + string employee_number = 8; + string address = 9; + string mobile = 10; + string email = 11; + string organization = 12; + int32 is_mdm = 13; + int32 state = 14; + int32 weight = 15; + string icon_file_id = 16; + repeated ExAttrVo attrs = 17; +} + +// ============================================================================= +// 6.2.1 GetUsers - 用户列表 +// ============================================================================= +message GetUsersRequest { + int32 index = 1; // 页号(0起始) + int32 size = 2; // 每页数量(默认10,最大5000) + int32 order_code = 3; // 排序字段 0用户名 1姓名 2工号 3用户状态 + int32 order_type = 4; // 排序类型 0 asc 1 desc + GetUsersCondition condition = 5; // 查询条件 + string org_code = 6; // 机构编码 + string appkey = 7; // 接入账号 + string sign = 8; // MD5签名(自动计算) +} + +message GetUsersResponse { + int32 code = 1; + string msg = 2; + GetUsersData data = 3; + string time_stamp = 4; +} + +message GetUsersData { + int32 total = 1; + repeated UserInfo user_infos = 2; +} + +// ============================================================================= +// 6.2.2 AddUser - 新增用户 +// ============================================================================= +message AddUserRequest { + string user_name = 1; // 用户姓名(必填) + string login_name = 2; // 登录名(必填) + string dept_id = 3; // 部门ID(必填) + string password = 4; // MBS要求的3DES加密后密码串(必填,由调用方提供) + string phone_number = 5; + string job = 6; + string employee_number = 7; + string address = 8; + string mobile = 9; + string email = 10; + string organization = 11; + int32 user_source = 12; // 0本地 2第三方(必填) + int32 is_mdm = 13; // 是否开启设备管理 0否 1是 + int32 state = 14; // 1启用 0停用 -1未激活 + int32 weight = 15; + repeated ExAttrVo attrs = 16; + string org_code = 17; + string appkey = 18; + string sign = 19; +} + +message AddUserResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; // 通常为空对象 +} + +// ============================================================================= +// 6.2.3 UpdUser - 编辑用户 +// ============================================================================= +message UpdUserRequest { + string user_id = 1; // 用户ID(必填) + string user_name = 2; // 用户姓名(必填) + string login_name = 3; // 登录名(不可修改账号) + string dept_id = 4; // 部门ID(必填) + string phone_number = 5; + string job = 6; + string employee_number = 7; + string address = 8; + string mobile = 9; + string email = 10; + string organization = 11; + int32 is_mdm = 12; + int32 weight = 13; + repeated ExAttrVo attrs = 14; + string org_code = 15; + string appkey = 16; + string sign = 17; +} + +message UpdUserResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.4 DetailUser - 用户详情 +// ============================================================================= +message DetailUserRequest { + string user_id = 1; + string org_code = 2; + string appkey = 3; + string sign = 4; +} + +message DetailUserResponse { + int32 code = 1; + string msg = 2; + UserDetail data = 3; +} + +// ============================================================================= +// 6.2.5 DelUsers - 删除用户 +// ============================================================================= +message DelUsersRequest { + repeated string user_ids = 1; // 用户ID列表 + int32 type = 2; // 0指定ID删除 1指定条件删除 + BatchCondition condition = 3; // type=1时的查询条件 + string org_code = 4; + string appkey = 5; + string sign = 6; +} + +message DelUsersResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.6 StateUsers - 启停用户 +// ============================================================================= +message StateUsersRequest { + repeated string user_ids = 1; + int32 type = 2; // 0指定ID 1指定条件 + string state = 3; // 0停用 1启用 + BatchCondition condition = 4; + string org_code = 5; + string appkey = 6; + string sign = 7; +} + +message StateUsersResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.7 CheckLoginName - 账号校验 +// ============================================================================= +message CheckLoginNameRequest { + string login_name = 1; + string org_code = 2; + string appkey = 3; + string sign = 4; +} + +message CheckLoginNameResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.8 GetUserByPhone - 根据手机号获取用户 +// ============================================================================= +message GetUserByPhoneRequest { + string phone = 1; + string org_code = 2; + string appkey = 3; + string sign = 4; +} + +message GetUserByPhoneResponse { + int32 code = 1; + string msg = 2; + repeated PhoneUserInfo data = 3; +} + +message PhoneUserInfo { + string user_id = 1; + string user_name = 2; + string login_name = 3; + string phone_number = 4; + string email = 5; +} + +// ============================================================================= +// 6.2.9 / 6.2.10 UpdUserPwd - 修改密码 +// ============================================================================= +message UpdUserPwdRequest { + string user_id = 1; // v1版本必填 + string password = 2; // MBS要求的3DES加密后新密码串(v1,由调用方提供) + string old_pwd = 3; // MBS要求的3DES加密后原密码串(v2,由调用方提供) + string new_pwd = 4; // MBS要求的3DES加密后新密码串(v2,由调用方提供) + string login_name = 5; // v2版本必填 + string version = 6; // "v1" 管理员修改 / "v2" 用户自服务 + string org_code = 7; + string appkey = 8; + string sign = 9; +} + +message UpdUserPwdResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.11 ForceOffline - 强制下线 +// ============================================================================= +message ForceOfflineRequest { + string user_id = 1; + string org_code = 2; + string appkey = 3; + string sign = 4; +} + +message ForceOfflineResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.12 ImportUser - 导入用户 +// ============================================================================= +message ImportUserRequest { + int32 lang = 1; // 0中文 1英文 2简体中文 + string file_id = 2; // 文件ID + string org_code = 3; + string appkey = 4; + string sign = 5; +} + +message ImportUserResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} \ No newline at end of file diff --git a/services/zhizhangyi__mbs/secret.schema.json b/services/zhizhangyi__mbs/secret.schema.json new file mode 100644 index 00000000..8e621f3c --- /dev/null +++ b/services/zhizhangyi__mbs/secret.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "appkey": { + "type": "string", + "description": "MBS third-party access appkey." + }, + "secretkey": { + "type": "string", + "description": "MBS third-party access secretkey paired with appkey." + }, + "orgCode": { + "type": "string", + "description": "Organization code (orgCode) for MBS tenant." + } + } +} diff --git a/services/zhizhangyi__mbs/service.json b/services/zhizhangyi__mbs/service.json new file mode 100644 index 00000000..9fb14081 --- /dev/null +++ b/services/zhizhangyi__mbs/service.json @@ -0,0 +1,69 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "zhizhangyi-mbs", + "displayName": "MBS Mobile Security Management", + "description": "OctoBus package for 指掌易 MBS (Mobile Security) User Management APIs. Provides CRUD for users, password management, account validation, force offline, and bulk import.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/zhizhangyi_mbs.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "zhizhangyi.mbs.UserManagement/GetUsers": { + "name": "get-users", + "description": "List MBS users with pagination, filtering, and sorting." + }, + "zhizhangyi.mbs.UserManagement/AddUser": { + "name": "add-user", + "description": "Create a new MBS user." + }, + "zhizhangyi.mbs.UserManagement/UpdUser": { + "name": "update-user", + "description": "Update an existing MBS user." + }, + "zhizhangyi.mbs.UserManagement/DetailUser": { + "name": "detail-user", + "description": "Get MBS user detail by user ID." + }, + "zhizhangyi.mbs.UserManagement/DelUsers": { + "name": "delete-users", + "description": "Delete MBS users by ID or condition." + }, + "zhizhangyi.mbs.UserManagement/StateUsers": { + "name": "state-users", + "description": "Enable or disable MBS users." + }, + "zhizhangyi.mbs.UserManagement/CheckLoginName": { + "name": "check-login-name", + "description": "Check if a login name is available." + }, + "zhizhangyi.mbs.UserManagement/GetUserByPhone": { + "name": "get-user-by-phone", + "description": "Find MBS users by phone number." + }, + "zhizhangyi.mbs.UserManagement/UpdUserPwd": { + "name": "update-user-password", + "description": "Change user password (admin or self-service)." + }, + "zhizhangyi.mbs.UserManagement/ForceOffline": { + "name": "force-offline", + "description": "Force a user to be logged out." + }, + "zhizhangyi.mbs.UserManagement/ImportUser": { + "name": "import-user", + "description": "Import users from an uploaded file." + } + } + } + } +} diff --git a/services/zhizhangyi__mbs/src/service.js b/services/zhizhangyi__mbs/src/service.js new file mode 100644 index 00000000..d69d1c52 --- /dev/null +++ b/services/zhizhangyi__mbs/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./zhizhangyi-mbs.js"; + +export { handlers } from "./zhizhangyi-mbs.js"; + +export const service = defineService({ handlers }); diff --git a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js new file mode 100644 index 00000000..c14be4cd --- /dev/null +++ b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js @@ -0,0 +1,280 @@ +// MBS User Management API Proxy - Part 1: helpers + first 6 handlers +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; +import crypto from 'node:crypto'; + +const DEF_TO = 30000; +const BASE = '/uusafe/mos/thirdaccess/rest/opt'; + +const has = (o, k) => Object.prototype.hasOwnProperty.call(o ?? {}, k); +const first = (...vs) => vs.find((v) => v !== undefined && v !== null); +const merge = (ctx = {}) => ({ ...(ctx?.config ?? {}), ...(ctx?.secret ?? {}), ...(ctx?.bindings ?? {}) }); +const normUrl = (u) => /^https?:\/\//i.test(String(u || '').trim()) ? String(u).trim().replace(/\/$/, '') : null; +const md5 = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex'); +const signCalc = (sk, ...ps) => md5(ps.map((p) => (p === undefined || p === null ? '' : String(p))).join('') + String(sk || '')); +const arr = (v) => Array.isArray(v) ? v : []; +const str = (v) => (v === undefined || v === null || v === '') ? undefined : String(v); +const num = (v) => (v === undefined || v === null || v === '') ? undefined : Number(v); +const gc = (c) => ({ INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, UNAVAILABLE: grpcStatus.UNAVAILABLE, DEADLINE_EXCEEDED: grpcStatus.DEADLINE_EXCEEDED })[c] ?? grpcStatus.UNKNOWN; +const er = (c, m) => { const e = new GrpcError(gc(c), c + ': ' + m); e.legacyCode = c; return e; }; +const doFetch = async (url, init, to, st) => { const tls = st ? { insecureSkipVerify: true, tlsInsecureSkipVerify: true } : {}; try { return await fetch(url, { ...init, timeoutMs: to, ...tls }); } catch (e) { throw er('UNAVAILABLE', e?.cause?.message || e?.message || 'fetch failed'); } }; +const rdJson = async (res) => { const t = await res.text(); if (!res.ok) { const safe = t.length > 200 ? t.slice(0, 200) + '...' : t; throw er(res.status === 401 || res.status === 403 ? 'PERMISSION_DENIED' : res.status >= 400 && res.status < 500 ? 'FAILED_PRECONDITION' : 'UNAVAILABLE', 'http ' + res.status + ': ' + safe); } if (!t.trim()) return {}; try { return JSON.parse(t); } catch { throw er('UNKNOWN', 'not JSON'); } }; +const check = (j) => { if (j.code !== undefined && j.code !== 0) throw er('FAILED_PRECONDITION', 'MBS code=' + j.code + ': ' + (j.msg || '')); }; +const buildCond = (cond) => { const c = {}; const kw = first(cond?.key_word, cond?.keyWord); if (kw !== undefined) c.keyWord = kw; if (cond?.status !== undefined && cond?.status !== null) c.status = cond.status; const im = first(cond?.is_mdm, cond?.isMdm); if (im !== undefined) c.isMdm = im; const did = first(cond?.dept_id, cond?.deptId); if (did !== undefined) c.deptId = did; return c; }; +const toValue = (value) => { + if (value === undefined || value === null) return { nullValue: 'NULL_VALUE' }; + if (typeof value === 'string') return { stringValue: value }; + if (typeof value === 'number') return Number.isFinite(value) ? { numberValue: value } : { stringValue: String(value) }; + if (typeof value === 'boolean') return { boolValue: value }; + if (Array.isArray(value)) return { listValue: { values: value.map((item) => toValue(item)) } }; + if (typeof value === 'object') { + const fields = {}; + for (const [k, v] of Object.entries(value)) fields[k] = toValue(v); + return { structValue: { fields } }; + } + return { stringValue: String(value) }; +}; + +const mapUser = (it) => ({ user_id: it?.userId ?? '', user_name: it?.userName ?? '', login_name: it?.loginName ?? '', phone_number: it?.phoneNumber ?? '', email: it?.email ?? '', employee_number: it?.employeeNumber ?? '', dept_id: it?.deptId ?? '', dept_name: it?.deptName ?? '', device_count: it?.deviceCount ?? 0, is_mdm: it?.isMdm ?? 0, state: it?.state ?? 0, is_admin: it?.isAdmin ?? 0, user_source: it?.userSource ?? 0, status: it?.status ?? 1, weight: it?.weight ?? 0, attrs: arr(it?.attrs).map((a) => ({ attr_key: a?.attrKey ?? '', attr_value: a?.attrValue ?? '' })), dept_full_id: it?.deptFullId ?? '', dept_full_path: it?.deptFullPath ?? '', job: it?.job ?? '', mobile: it?.mobile ?? '', address: it?.address ?? '', organization: it?.organization ?? '' }); +const mapDetail = (it) => ({ user_id: it?.userId ?? '', user_name: it?.userName ?? '', login_name: it?.loginName ?? '', dept_id: it?.deptId ?? '', dept_name: it?.deptName ?? '', phone_number: it?.phoneNumber ?? '', job: it?.job ?? '', employee_number: it?.employeeNumber ?? '', address: it?.address ?? '', mobile: it?.mobile ?? '', email: it?.email ?? '', organization: it?.organization ?? '', is_mdm: it?.isMdm ?? 0, state: it?.state ?? 0, weight: it?.weight ?? 0, icon_file_id: it?.iconFileId ?? '', attrs: arr(it?.attrs).map((a) => ({ attr_key: a?.attrKey ?? '', attr_value: a?.attrValue ?? '' })) }); +const mapPhone = (it) => ({ user_id: it?.userId ?? '', user_name: it?.userName ?? '', login_name: it?.loginName ?? '', phone_number: it?.phoneNumber ?? '', email: it?.email ?? '' }); + +const M = { + GetUsers: 'zhizhangyi.mbs.UserManagement/GetUsers', + AddUser: 'zhizhangyi.mbs.UserManagement/AddUser', + UpdUser: 'zhizhangyi.mbs.UserManagement/UpdUser', + DetailUser: 'zhizhangyi.mbs.UserManagement/DetailUser', + DelUsers: 'zhizhangyi.mbs.UserManagement/DelUsers', + StateUsers: 'zhizhangyi.mbs.UserManagement/StateUsers', + CheckLoginName: 'zhizhangyi.mbs.UserManagement/CheckLoginName', + GetUserByPhone: 'zhizhangyi.mbs.UserManagement/GetUserByPhone', + UpdUserPwd: 'zhizhangyi.mbs.UserManagement/UpdUserPwd', + ForceOffline: 'zhizhangyi.mbs.UserManagement/ForceOffline', + ImportUser: 'zhizhangyi.mbs.UserManagement/ImportUser', +}; + +export function rpcdef(ctx) { + const b = merge(ctx); + const baseUrl = normUrl(b.endpoint || b.baseUrl || ''); + const to = ctx?.limits?.timeoutMs || b.timeoutMs || DEF_TO; + const skipTls = Boolean(b.skipTlsVerify); + const ak = b.appkey || ''; + const sk = b.secretkey || ''; + const ocDef = b.orgCode || ''; + + const post = async (path, body) => { + if (!baseUrl) throw er('INVALID_ARGUMENT', 'endpoint/baseUrl required'); + const r = await doFetch(baseUrl + path, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body) }, to, skipTls); + const j = await rdJson(r); check(j); return j; + }; + + // 6.2.1 GetUsers + const goGetUsers = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; + const apk = first(req?.appkey) || ak; + const idx = first(req?.index) ?? 0; const sz = first(req?.size) ?? 10; + const odc = first(req?.order_code, req?.orderCode) ?? 0; const odt = first(req?.order_type, req?.orderType) ?? 1; + const did = first(req?.condition?.dept_id, req?.condition?.deptId); + if (!did) throw er('INVALID_ARGUMENT', 'dept_id required'); + const kw = first(req?.condition?.key_word, req?.condition?.keyWord) || ''; + const st = first(req?.condition?.state) ?? ''; const md = first(req?.condition?.is_mdm, req?.condition?.isMdm) ?? ''; + const sg = first(req?.sign) || signCalc(sk, apk, oc, idx, sz, odc, odt, kw, st, md, did); + const cond = { deptId: did, keyWord: kw }; if (st !== '') cond.state = st; if (md !== '') cond.isMdm = md; + const j = await post(BASE + '/v1/getUsers', { index: Number(idx), size: Number(sz), orderCode: Number(odc), orderType: Number(odt), condition: cond, orgCode: oc, appkey: apk, sign: sg }); + const d = j?.data || {}; + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: { total: d.total ?? 0, user_infos: arr(d.userInfos).map(mapUser) }, time_stamp: j?.timeStamp ?? '' }; + }; + + // 6.2.2 AddUser + const goAddUser = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const un = first(req?.user_name, req?.userName) || ''; const ln = first(req?.login_name, req?.loginName) || ''; + const did = first(req?.dept_id, req?.deptId) || ''; const encryptedPw = first(req?.password) || ''; + if (!un) throw er('INVALID_ARGUMENT', 'user_name required'); if (!ln) throw er('INVALID_ARGUMENT', 'login_name required'); if (!did) throw er('INVALID_ARGUMENT', 'dept_id required'); if (!encryptedPw) throw er('INVALID_ARGUMENT', 'password required as 3DES-encrypted value'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, un, ln, did, encryptedPw); + const body = { userName: un, loginName: ln, deptId: did, password: encryptedPw, userSource: Number(first(req?.user_source, req?.userSource) ?? 0), orgCode: oc, appkey: apk, sign: sg }; + const sm = { phone_number: 'phoneNumber', job: 'job', employee_number: 'employeeNumber', address: 'address', mobile: 'mobile', email: 'email', organization: 'organization' }; + for (const [k, jk] of Object.entries(sm)) { const v = str(first(req?.[k])); if (v !== undefined) body[jk] = v; } + for (const k of ['is_mdm', 'state', 'weight']) { const v = num(first(req?.[k])); if (v !== undefined) body[k === 'is_mdm' ? 'isMdm' : k] = v; } + if (arr(req?.attrs).length > 0) body.attrs = req.attrs.map((a) => ({ attrKey: a?.attr_key ?? '', attrValue: a?.attr_value ?? '' })); + const j = await post(BASE + '/v1/addUser', body); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.3 UpdUser + const goUpdUser = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const uid = first(req?.user_id, req?.userId) || ''; const un = first(req?.user_name, req?.userName) || ''; + if (!uid) throw er('INVALID_ARGUMENT', 'user_id required'); if (!un) throw er('INVALID_ARGUMENT', 'user_name required'); + const ln = first(req?.login_name, req?.loginName) || ''; const did = first(req?.dept_id, req?.deptId) || ''; + if (!did) throw er('INVALID_ARGUMENT', 'dept_id required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, uid, un, ln, did); + const body = { userId: uid, userName: un, loginName: ln, deptId: did, orgCode: oc, appkey: apk, sign: sg }; + const sm = { phone_number: 'phoneNumber', job: 'job', employee_number: 'employeeNumber', address: 'address', mobile: 'mobile', email: 'email', organization: 'organization' }; + for (const [k, jk] of Object.entries(sm)) { const v = str(first(req?.[k])); if (v !== undefined) body[jk] = v; } + for (const k of ['is_mdm', 'weight']) { const v = num(first(req?.[k])); if (v !== undefined) body[k === 'is_mdm' ? 'isMdm' : k] = v; } + if (arr(req?.attrs).length > 0) body.attrs = req.attrs.map((a) => ({ attrKey: a?.attr_key ?? '', attrValue: a?.attr_value ?? '' })); + const j = await post(BASE + '/v1/updUser', body); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.4 DetailUser + const goDetailUser = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const uid = first(req?.user_id, req?.userId) || ''; if (!uid) throw er('INVALID_ARGUMENT', 'user_id required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, uid); + const j = await post(BASE + '/v1/detailUser', { userId: uid, orgCode: oc, appkey: apk, sign: sg }); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: j?.data ? mapDetail(j.data) : null }; + }; + + // 6.2.5 DelUsers + const goDelUsers = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const tp = Number(first(req?.type) ?? 0); const uids = arr(first(req?.user_ids, req?.userIds)); + const did = first(req?.condition?.dept_id, req?.condition?.deptId) || ''; + const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, did); + const body = { type: tp, orgCode: oc, appkey: apk, sign: sg }; + if (tp === 0) { body.userIds = uids; } else { const c = buildCond(req?.condition); if (Object.keys(c).length > 0) body.condition = c; } + const j = await post(BASE + '/v1/delUsers', body); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.6 StateUsers + const goStateUsers = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const tp = Number(first(req?.type) ?? 0); const st = first(req?.state); + if (st === undefined || st === null) throw er('INVALID_ARGUMENT', 'state required'); + const uids = arr(first(req?.user_ids, req?.userIds)); + const did = first(req?.condition?.dept_id, req?.condition?.deptId) || ''; + const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, st, did); + const body = { type: tp, state: st, orgCode: oc, appkey: apk, sign: sg }; + if (tp === 0) { body.userIds = uids; } else { const c = buildCond(req?.condition); if (Object.keys(c).length > 0) body.condition = c; } + const j = await post(BASE + '/v1/stateUsers', body); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.7 CheckLoginName + const goCheckLoginName = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const ln = first(req?.login_name, req?.loginName) || ''; if (!ln) throw er('INVALID_ARGUMENT', 'login_name required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, ln); + const j = await post(BASE + '/v1/checkLoginName', { loginName: ln, orgCode: oc, appkey: apk, sign: sg }); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.8 GetUserByPhone + const goGetUserByPhone = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const ph = first(req?.phone) || ''; if (!ph) throw er('INVALID_ARGUMENT', 'phone required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, ph); + const j = await post(BASE + '/v1/getUserByPhone', { phone: ph, orgCode: oc, appkey: apk, sign: sg }); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: arr(j?.data).map(mapPhone) }; + }; + + // 6.2.9/6.2.10 UpdUserPwd + const goUpdUserPwd = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const ver = first(req?.version) || 'v1'; + if (ver !== 'v1' && ver !== 'v2') throw er('INVALID_ARGUMENT', 'version must be v1 or v2'); + let body; + if (ver === 'v2') { + const ln = first(req?.login_name, req?.loginName) || ''; const encryptedNewPwd = first(req?.new_pwd, req?.newPwd) || ''; + if (!ln) throw er('INVALID_ARGUMENT', 'login_name required for v2'); if (!encryptedNewPwd) throw er('INVALID_ARGUMENT', 'new_pwd required as 3DES-encrypted value for v2'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, ln, encryptedNewPwd); + body = { loginName: ln, newPwd: encryptedNewPwd, orgCode: oc, appkey: apk, sign: sg }; + const encryptedOldPwd = first(req?.old_pwd, req?.oldPwd); if (encryptedOldPwd) body.oldPwd = encryptedOldPwd; + } else { + const uid = first(req?.user_id, req?.userId) || ''; const encryptedPw = first(req?.password) || ''; + if (!uid) throw er('INVALID_ARGUMENT', 'user_id required for v1'); if (!encryptedPw) throw er('INVALID_ARGUMENT', 'password required as 3DES-encrypted value for v1'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, uid, encryptedPw); + body = { userId: uid, password: encryptedPw, orgCode: oc, appkey: apk, sign: sg }; + } + const j = await post(BASE + `/${ver}/updUserPwd`, body); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.11 ForceOffline + const goForceOffline = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const uid = first(req?.user_id, req?.userId) || ''; if (!uid) throw er('INVALID_ARGUMENT', 'user_id required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, uid); + const j = await post(BASE + '/v1/forceOffline', { userId: uid, orgCode: oc, appkey: apk, sign: sg }); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.12 ImportUser + const goImportUser = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const lang = first(req?.lang) ?? 0; const fid = first(req?.file_id, req?.fileId) || ''; + if (!fid) throw er('INVALID_ARGUMENT', 'file_id required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, lang, fid); + const j = await post(BASE + '/v1/importUser', { lang: Number(lang), fileId: fid, orgCode: oc, appkey: apk, sign: sg }); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + return { + [M.GetUsers]: async () => goGetUsers(ctx.req || {}), + [M.AddUser]: async () => goAddUser(ctx.req || {}), + [M.UpdUser]: async () => goUpdUser(ctx.req || {}), + [M.DetailUser]: async () => goDetailUser(ctx.req || {}), + [M.DelUsers]: async () => goDelUsers(ctx.req || {}), + [M.StateUsers]: async () => goStateUsers(ctx.req || {}), + [M.CheckLoginName]: async () => goCheckLoginName(ctx.req || {}), + [M.GetUserByPhone]: async () => goGetUserByPhone(ctx.req || {}), + [M.UpdUserPwd]: async () => goUpdUserPwd(ctx.req || {}), + [M.ForceOffline]: async () => goForceOffline(ctx.req || {}), + [M.ImportUser]: async () => goImportUser(ctx.req || {}), + }; +} + + +// Legacy handler wrapper +function wrapLegacyHandler(baseCtx, methodPath) { + return async function(reqOrCtx, maybeInnerCtx) { + var incoming = (reqOrCtx && typeof reqOrCtx === 'object') ? reqOrCtx : {}; + var callCtx = { + ...(baseCtx ?? {}), + ...incoming, + req: incoming.request ?? incoming.req ?? reqOrCtx ?? {}, + request: incoming.request ?? incoming.req ?? reqOrCtx ?? {}, + config: incoming.config ?? baseCtx?.config, + secret: incoming.secret ?? baseCtx?.secret, + metadata: incoming.metadata ?? baseCtx?.metadata, + meta: incoming.meta ?? baseCtx?.meta, + getMetadata: incoming.getMetadata ?? baseCtx?.getMetadata, + getMetadataAll: incoming.getMetadataAll ?? baseCtx?.getMetadataAll, + }; + return rpcdef(callCtx)[methodPath](); + }; +} + +function registerHandlers(ctx) { + return { + [M.GetUsers]: wrapLegacyHandler(ctx, M.GetUsers), + [M.AddUser]: wrapLegacyHandler(ctx, M.AddUser), + [M.UpdUser]: wrapLegacyHandler(ctx, M.UpdUser), + [M.DetailUser]: wrapLegacyHandler(ctx, M.DetailUser), + [M.DelUsers]: wrapLegacyHandler(ctx, M.DelUsers), + [M.StateUsers]: wrapLegacyHandler(ctx, M.StateUsers), + [M.CheckLoginName]: wrapLegacyHandler(ctx, M.CheckLoginName), + [M.GetUserByPhone]: wrapLegacyHandler(ctx, M.GetUserByPhone), + [M.UpdUserPwd]: wrapLegacyHandler(ctx, M.UpdUserPwd), + [M.ForceOffline]: wrapLegacyHandler(ctx, M.ForceOffline), + [M.ImportUser]: wrapLegacyHandler(ctx, M.ImportUser), + }; +} + +var sdkHandlers = registerHandlers({}); + +export var handlers = { + [M.GetUsers]: (ctx) => sdkHandlers[M.GetUsers](ctx), + [M.AddUser]: (ctx) => sdkHandlers[M.AddUser](ctx), + [M.UpdUser]: (ctx) => sdkHandlers[M.UpdUser](ctx), + [M.DetailUser]: (ctx) => sdkHandlers[M.DetailUser](ctx), + [M.DelUsers]: (ctx) => sdkHandlers[M.DelUsers](ctx), + [M.StateUsers]: (ctx) => sdkHandlers[M.StateUsers](ctx), + [M.CheckLoginName]: (ctx) => sdkHandlers[M.CheckLoginName](ctx), + [M.GetUserByPhone]: (ctx) => sdkHandlers[M.GetUserByPhone](ctx), + [M.UpdUserPwd]: (ctx) => sdkHandlers[M.UpdUserPwd](ctx), + [M.ForceOffline]: (ctx) => sdkHandlers[M.ForceOffline](ctx), + [M.ImportUser]: (ctx) => sdkHandlers[M.ImportUser](ctx), +}; diff --git a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js new file mode 100644 index 00000000..f62300c5 --- /dev/null +++ b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js @@ -0,0 +1,364 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +import { rpcdef } from '../src/zhizhangyi-mbs.js'; + +const ADD_USER = 'zhizhangyi.mbs.UserManagement/AddUser'; +const DEL_USERS = 'zhizhangyi.mbs.UserManagement/DelUsers'; +const GET_USERS = 'zhizhangyi.mbs.UserManagement/GetUsers'; +const STATE_USERS = 'zhizhangyi.mbs.UserManagement/StateUsers'; +const UPD_USER = 'zhizhangyi.mbs.UserManagement/UpdUser'; +const UPD_USER_PWD = 'zhizhangyi.mbs.UserManagement/UpdUserPwd'; + +const originalFetch = globalThis.fetch; + +const buildCtx = (req) => ({ + config: { endpoint: 'https://mbs.example' }, + secret: { appkey: 'appkey', secretkey: 'secretkey', orgCode: 'org' }, + req, +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test('AddUser requires password before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ user_name: 'User', login_name: 'user', dept_id: 'dept' }))[ADD_USER](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /password required/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('GetUsers preserves falsy state and is_mdm filters', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"total":0,"userInfos":[]}}' }; + }; + + await rpcdef(buildCtx({ condition: { dept_id: '1', state: 0, is_mdm: 0 } }))[GET_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/getUsers'); + assert.deepEqual(JSON.parse(captured.init.body).condition, { + deptId: '1', + keyWord: '', + state: 0, + isMdm: 0, + }); +}); + +test('GetUsers requires dept_id before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ condition: { state: 1 } }))[GET_USERS](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /dept_id required/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('HTTP errors expose only a bounded upstream body summary', async () => { + const body = 'x'.repeat(260); + globalThis.fetch = async () => ({ ok: false, status: 500, text: async () => body }); + + await assert.rejects( + () => rpcdef(buildCtx({ condition: { dept_id: '1' } }))[GET_USERS](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.UNAVAILABLE); + assert.match(err.message, /http 500: x{200}\.\.\./); + assert.equal(err.message.includes('x'.repeat(220)), false); + return true; + }, + ); +}); + +test('AddUser forwards caller-provided 3DES-encrypted password value', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"created":true}}' }; + }; + + const result = await rpcdef(buildCtx({ user_name: 'User', login_name: 'user', dept_id: 'dept', password: '3des-ciphertext' }))[ADD_USER](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/addUser'); + assert.equal(JSON.parse(captured.init.body).password, '3des-ciphertext'); + assert.equal(result.data.structValue.fields.created.boolValue, true); +}); + +test('AddUser omits empty numeric fields instead of sending zero values', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{}}' }; + }; + + await rpcdef(buildCtx({ user_name: 'User', login_name: 'user', dept_id: 'dept', password: '3des-ciphertext', is_mdm: '', state: '', weight: '' }))[ADD_USER](); + + const body = JSON.parse(captured.init.body); + assert.equal(Object.hasOwn(body, 'isMdm'), false); + assert.equal(Object.hasOwn(body, 'state'), false); + assert.equal(Object.hasOwn(body, 'weight'), false); +}); + +test('UpdUser requires dept_id before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ user_id: 'user-1', user_name: 'User' }))[UPD_USER](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /dept_id required/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('UpdUser omits empty numeric fields instead of sending zero values', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{}}' }; + }; + + await rpcdef(buildCtx({ user_id: 'user-1', user_name: 'User', dept_id: 'dept', is_mdm: '', weight: '' }))[UPD_USER](); + + const body = JSON.parse(captured.init.body); + assert.equal(Object.hasOwn(body, 'isMdm'), false); + assert.equal(Object.hasOwn(body, 'weight'), false); +}); + +test('StateUsers requires explicit state before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ type: 0, user_ids: ['user-1'] }))[STATE_USERS](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /state required/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('DelUsers treats string type zero as userIds mode', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await rpcdef(buildCtx({ type: '0', user_ids: ['user-1'] }))[DEL_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/delUsers'); + assert.deepEqual(JSON.parse(captured.init.body).userIds, ['user-1']); + assert.equal(JSON.parse(captured.init.body).type, 0); + assert.equal(Object.hasOwn(JSON.parse(captured.init.body), 'condition'), false); +}); + +test('DelUsers condition mode preserves falsy condition filters', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":null}' }; + }; + + const result = await rpcdef(buildCtx({ type: 1, condition: { key_word: '', status: 0, is_mdm: 0, dept_id: 'dept' } }))[DEL_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/delUsers'); + assert.deepEqual(JSON.parse(captured.init.body).condition, { + keyWord: '', + status: 0, + isMdm: 0, + deptId: 'dept', + }); + assert.deepEqual(result.data, { nullValue: 'NULL_VALUE' }); +}); + +test('StateUsers condition mode preserves falsy condition filters', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"updated":2}}' }; + }; + + const result = await rpcdef(buildCtx({ type: 1, state: '0', condition: { status: 0, is_mdm: 0 } }))[STATE_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/stateUsers'); + assert.equal(JSON.parse(captured.init.body).state, '0'); + assert.deepEqual(JSON.parse(captured.init.body).condition, { + status: 0, + isMdm: 0, + }); + assert.equal(result.data.structValue.fields.updated.numberValue, 2); +}); + +test('UpdUserPwd rejects invalid version before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ version: '../v1', user_id: 'user-1', password: '3des-ciphertext' }))[UPD_USER_PWD](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /version must be v1 or v2/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('UpdUserPwd v1 requires user_id and password before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ version: 'v1', password: '3des-ciphertext' }))[UPD_USER_PWD](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /user_id required for v1/); + return true; + }, + ); + await assert.rejects( + () => rpcdef(buildCtx({ version: 'v1', user_id: 'user-1' }))[UPD_USER_PWD](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /password required as 3DES-encrypted value for v1/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('UpdUserPwd v1 posts to v1 path with encrypted password', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{}}' }; + }; + + const result = await rpcdef(buildCtx({ version: 'v1', user_id: 'user-1', password: '3des-ciphertext' }))[UPD_USER_PWD](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/updUserPwd'); + assert.equal(JSON.parse(captured.init.body).userId, 'user-1'); + assert.equal(JSON.parse(captured.init.body).password, '3des-ciphertext'); + assert.deepEqual(result.data, { structValue: { fields: {} } }); +}); + +test('UpdUserPwd v2 requires login_name and new_pwd before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ version: 'v2', new_pwd: 'new-ciphertext' }))[UPD_USER_PWD](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /login_name required for v2/); + return true; + }, + ); + await assert.rejects( + () => rpcdef(buildCtx({ version: 'v2', login_name: 'user' }))[UPD_USER_PWD](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /new_pwd required as 3DES-encrypted value for v2/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('UpdUserPwd v2 posts to v2 path and forwards oldPwd', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"changed":true}}' }; + }; + + const result = await rpcdef(buildCtx({ version: 'v2', login_name: 'user', old_pwd: 'old-ciphertext', new_pwd: 'new-ciphertext' }))[UPD_USER_PWD](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v2/updUserPwd'); + assert.equal(JSON.parse(captured.init.body).loginName, 'user'); + assert.equal(JSON.parse(captured.init.body).oldPwd, 'old-ciphertext'); + assert.equal(JSON.parse(captured.init.body).newPwd, 'new-ciphertext'); + assert.equal(result.data.structValue.fields.changed.boolValue, true); +}); + +test('StateUsers treats string type zero as userIds mode', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await rpcdef(buildCtx({ type: '0', state: '1', user_ids: ['user-1'] }))[STATE_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/stateUsers'); + assert.deepEqual(JSON.parse(captured.init.body).userIds, ['user-1']); + assert.equal(JSON.parse(captured.init.body).type, 0); + assert.equal(JSON.parse(captured.init.body).state, '1'); + assert.equal(Object.hasOwn(JSON.parse(captured.init.body), 'condition'), false); +}); diff --git a/services/zhizhangyi__mbs/testdata/test_4apis.mjs b/services/zhizhangyi__mbs/testdata/test_4apis.mjs new file mode 100644 index 00000000..1a7fe25f --- /dev/null +++ b/services/zhizhangyi__mbs/testdata/test_4apis.mjs @@ -0,0 +1,130 @@ +// MBS API 测试: getUsers / detailUser / checkLoginName / stateUsers +import crypto from 'node:crypto'; +import https from 'node:https'; + +const C = { + baseUrl: process.env.MBS_BASE_URL || 'https://127.0.0.1:9074', + orgCode: process.env.MBS_ORG_CODE || '', + appkey: process.env.MBS_APPKEY || '', + secretkey: process.env.MBS_SECRETKEY || '', +}; +const requireConfig = () => { + const missing = Object.entries(C).filter(([, value]) => !value).map(([key]) => key); + if (missing.length > 0) { + throw new Error(`Missing MBS test config: ${missing.join(', ')}. Set MBS_BASE_URL, MBS_ORG_CODE, MBS_APPKEY and MBS_SECRETKEY.`); + } +}; +requireConfig(); +const BASE = `${C.baseUrl}/uusafe/mos/thirdaccess/rest/opt`; +const agent = new https.Agent({ rejectUnauthorized: false }); +const md5 = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex'); +const sign = (...ps) => md5(ps.map((p) => (p ?? '')).join('') + C.secretkey); + +const post = async (path, body) => { + const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body), agent }); + const text = await res.text(); + return { status: res.status, json: JSON.parse(text) }; +}; + +(async () => { + const { appkey, orgCode } = C; + + // ── Test 1: getUsers ── + console.log('══════════════════════════════════════════'); + console.log('Test 1: getUsers (用户列表)'); + console.log('══════════════════════════════════════════'); + try { + const r1 = await post('/v1/getUsers', { + index: 0, size: 10, orderCode: 0, orderType: 1, + condition: { deptId: '1', keyWord: '' }, + orgCode, appkey, + sign: sign(appkey, orgCode, 0, 10, 0, 1, '', '', '', '1'), + }); + console.log(`HTTP ${r1.status} code=${r1.json.code}`); + if (r1.json.code === 0) { + console.log(` 共 ${r1.json.data.total} 个用户`); + (r1.json.data.userInfos || []).forEach((u, i) => + console.log(` [${i + 1}] ${u.userName} (${u.loginName}) userId=${u.userId} state=${u.state}`)); + } else { console.log(` ❌ ${r1.json.msg}`); } + } catch (e) { console.log(` ❌ ${e.message}`); } + + // ── Test 2: detailUser (test1) ── + console.log('\n══════════════════════════════════════════'); + console.log('Test 2: detailUser (用户详情 - test1)'); + console.log('══════════════════════════════════════════'); + const uid = '1691979294102310912'; // test1 + try { + const r2 = await post('/v1/detailUser', { + userId: uid, orgCode, appkey, + sign: sign(appkey, orgCode, uid), + }); + console.log(`HTTP ${r2.status} code=${r2.json.code}`); + if (r2.json.code === 0) { + const d = r2.json.data; + console.log(` ID: ${d.userId}`); + console.log(` 姓名: ${d.userName}`); + console.log(` 登录名: ${d.loginName}`); + console.log(` 部门: ${d.deptName} (${d.deptId})`); + console.log(` 手机: ${d.phoneNumber || '(空)'}`); + console.log(` 邮箱: ${d.email || '(空)'}`); + console.log(` 状态: ${d.state === 1 ? '启用' : d.state === 0 ? '停用' : '未激活'}`); + console.log(` 设备管理: ${d.isMdm ? '开启' : '关闭'}`); + } else { console.log(` ❌ ${r2.json.msg}`); } + } catch (e) { console.log(` ❌ ${e.message}`); } + + // ── Test 3: checkLoginName ── + console.log('\n══════════════════════════════════════════'); + console.log('Test 3: checkLoginName (账号校验)'); + console.log('══════════════════════════════════════════'); + for (const name of ['test1', 'nonexistent_user']) { + try { + const r3 = await post('/v1/checkLoginName', { + loginName: name, orgCode, appkey, + sign: sign(appkey, orgCode, name), + }); + const ok = r3.json.code === 0; + console.log(` "${name}" -> HTTP ${r3.status} code=${r3.json.code} ${ok ? '✅ 可用' : '❌ ' + r3.json.msg}`); + } catch (e) { console.log(` "${name}" -> ❌ ${e.message}`); } + } + + // ── Test 4: stateUsers (启停 - 先停用 test2 再启用) ── + console.log('\n══════════════════════════════════════════'); + console.log('Test 4: stateUsers (用户启停 - test2)'); + console.log('══════════════════════════════════════════'); + const uid2 = '1691979421122613248'; // test2 + + // 4a: 停用 + console.log(' 4a: 停用 test2...'); + try { + const r4a = await post('/v1/stateUsers', { + userIds: [uid2], type: 0, state: '0', + orgCode, appkey, + sign: sign(appkey, orgCode, uid2, 0, '0', ''), + }); + console.log(` HTTP ${r4a.status} code=${r4a.json.code} ${r4a.json.code === 0 ? '✅ 停用成功' : '❌ ' + r4a.json.msg}`); + } catch (e) { console.log(` ❌ ${e.message}`); } + + // 4b: 验证 - 查详情看 state + try { + const r4b = await post('/v1/detailUser', { + userId: uid2, orgCode, appkey, + sign: sign(appkey, orgCode, uid2), + }); + console.log(` 4b: 验证停用后详情 state=${r4b.json.data?.state} ${r4b.json.data?.state === 0 ? '✅ 已停用' : '⚠️'}`); + } catch (e) { console.log(` 4b: ❌ ${e.message}`); } + + // 4c: 重新启用 + console.log(' 4c: 重新启用 test2...'); + try { + const r4c = await post('/v1/stateUsers', { + userIds: [uid2], type: 0, state: '1', + orgCode, appkey, + sign: sign(appkey, orgCode, uid2, 0, '1', ''), + }); + console.log(` HTTP ${r4c.status} code=${r4c.json.code} ${r4c.json.code === 0 ? '✅ 重新启用成功' : '❌ ' + r4c.json.msg}`); + } catch (e) { console.log(` ❌ ${e.message}`); } + + console.log('\n══════════════════════════════════════════'); + console.log('全部测试完成'); + console.log('══════════════════════════════════════════'); +})(); diff --git a/services/zhizhangyi__mbs/testdata/test_getusers.mjs b/services/zhizhangyi__mbs/testdata/test_getusers.mjs new file mode 100644 index 00000000..a1e4d0ff --- /dev/null +++ b/services/zhizhangyi__mbs/testdata/test_getusers.mjs @@ -0,0 +1,104 @@ +// MBS getUsers API 直连测试 +// 不依赖 OctoBus SDK,直接用 Node.js 原生 fetch 调用 + +import crypto from 'node:crypto'; +import https from 'node:https'; + +const CONFIG = { + baseUrl: process.env.MBS_BASE_URL || 'https://127.0.0.1:9074', + orgCode: process.env.MBS_ORG_CODE || '', + appkey: process.env.MBS_APPKEY || '', + secretkey: process.env.MBS_SECRETKEY || '', +}; + +const md5 = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex'); + +// sign = MD5(appkey + orgCode + index + size + orderCode + orderType + keyword + state + isMdm + deptId + secretkey) +// 拼接时不包含 "+" +const computeSign = (params) => { + const joined = params.map((p) => (p === undefined || p === null ? '' : String(p))).join(''); + return md5(joined + CONFIG.secretkey); +}; + +const requireConfig = () => { + const missing = Object.entries(CONFIG).filter(([, value]) => !value).map(([key]) => key); + if (missing.length > 0) { + throw new Error(`Missing MBS test config: ${missing.join(', ')}. Set MBS_BASE_URL, MBS_ORG_CODE, MBS_APPKEY and MBS_SECRETKEY.`); + } +}; + +const testGetUsers = async () => { + requireConfig(); + const { baseUrl, orgCode, appkey } = CONFIG; + + const index = 0; + const size = 10; + const orderCode = 0; + const orderType = 1; + const keyword = ''; + const state = ''; + const isMdm = ''; + const deptId = '1'; + + const sign = computeSign([appkey, orgCode, index, size, orderCode, orderType, keyword, state, isMdm, deptId]); + + const body = { + index, + size, + orderCode, + orderType, + condition: { deptId, keyWord: keyword }, + orgCode, + appkey, + sign, + }; + + console.log('=== MBS getUsers 测试 ==='); + console.log('URL:', `${baseUrl}/uusafe/mos/thirdaccess/rest/opt/v1/getUsers`); + console.log('Body:', JSON.stringify(body, null, 2)); + console.log('Sign:', sign); + console.log(''); + + // 忽略自签名证书 + const agent = new https.Agent({ rejectUnauthorized: false }); + + try { + const res = await fetch(`${baseUrl}/uusafe/mos/thirdaccess/rest/opt/v1/getUsers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(body), + agent, + }); + + const text = await res.text(); + console.log('HTTP Status:', res.status); + console.log('Response headers:', Object.fromEntries(res.headers.entries())); + + let json; + try { + json = JSON.parse(text); + console.log('\n✅ 响应 JSON:'); + console.log(JSON.stringify(json, null, 2)); + + if (json.code === 0) { + console.log(`\n✅ 成功!共 ${json.data?.total ?? 0} 个用户`); + const users = json.data?.userInfos; + if (Array.isArray(users)) { + users.forEach((u, i) => { + console.log(` [${i + 1}] ${u.userName} (${u.loginName}) - ${u.deptName} - ${u.state === 1 ? '启用' : u.state === 0 ? '停用' : '未激活'}`); + }); + } + } else { + console.log(`\n❌ MBS 返回错误: code=${json.code}, msg=${json.msg}`); + } + } catch { + console.log('\n⚠️ 非 JSON 响应:'); + console.log(text.substring(0, 500)); + } + } catch (e) { + console.log('\n❌ 网络错误:', e.message); + if (e.cause) console.log(' 原因:', e.cause.message); + } +}; + +testGetUsers(); diff --git a/services/zhizhangyi__mbs/testdata/test_scenarios.mjs b/services/zhizhangyi__mbs/testdata/test_scenarios.mjs new file mode 100644 index 00000000..e01affb8 --- /dev/null +++ b/services/zhizhangyi__mbs/testdata/test_scenarios.mjs @@ -0,0 +1,101 @@ +// MBS 场景测试: test2停用 / test1设备激活停用 / test1开启设备管控 +import crypto from 'node:crypto'; +import https from 'node:https'; + +const C = { + baseUrl: process.env.MBS_BASE_URL || 'https://127.0.0.1:9074', + orgCode: process.env.MBS_ORG_CODE || '', + appkey: process.env.MBS_APPKEY || '', + secretkey: process.env.MBS_SECRETKEY || '', +}; +const requireConfig = () => { + const missing = Object.entries(C).filter(([, value]) => !value).map(([key]) => key); + if (missing.length > 0) { + throw new Error(`Missing MBS test config: ${missing.join(', ')}. Set MBS_BASE_URL, MBS_ORG_CODE, MBS_APPKEY and MBS_SECRETKEY.`); + } +}; +requireConfig(); +const BASE = `${C.baseUrl}/uusafe/mos/thirdaccess/rest/opt`; +const agent = new https.Agent({ rejectUnauthorized: false }); +const md5 = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex'); +const sign = (...ps) => md5(ps.map((p) => (p ?? '')).join('') + C.secretkey); + +const post = async (path, body) => { + const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body), agent }); + const text = await res.text(); + return { status: res.status, json: JSON.parse(text) }; +}; + +const { appkey, orgCode } = C; +const uid1 = '1691979294102310912'; // test1 +const uid2 = '1691979421122613248'; // test2 + +// 辅助: 查详情 +const detail = async (uid) => { + const r = await post('/v1/detailUser', { userId: uid, orgCode, appkey, sign: sign(appkey, orgCode, uid) }); + return r.json?.data; +}; + +(async () => { + // 先查初始状态 + console.log('=== 初始状态 ==='); + let d1 = await detail(uid1); + let d2 = await detail(uid2); + console.log(`test1: state=${d1.state} isMdm=${d1.isMdm}`); + console.log(`test2: state=${d2.state} isMdm=${d2.isMdm}`); + + // ═══ 场景1: test2 用户状态 → 停用 (state=0) ═══ + console.log('\n═══ 场景1: test2 用户状态 → 停用 (state=0) ═══'); + const r1 = await post('/v1/stateUsers', { + userIds: [uid2], type: 0, state: '0', + orgCode, appkey, + sign: sign(appkey, orgCode, uid2, 0, '0', ''), + }); + console.log(` stateUsers -> HTTP ${r1.status} code=${r1.json.code} ${r1.json.code === 0 ? '✅' : '❌'}`); + d2 = await detail(uid2); + console.log(` 验证: test2 state=${d2.state} ${d2.state === 0 ? '✅ 已停用' : '⚠️'}`); + + // 恢复 test2 + await post('/v1/stateUsers', { + userIds: [uid2], type: 0, state: '1', + orgCode, appkey, + sign: sign(appkey, orgCode, uid2, 0, '1', ''), + }); + + // ═══ 场景2: test1 设备激活状态 → 停用 (state=0) ═══ + console.log('\n═══ 场景2: test1 设备激活状态 → 停用 (state=0) ═══'); + const r2 = await post('/v1/stateUsers', { + userIds: [uid1], type: 0, state: '0', + orgCode, appkey, + sign: sign(appkey, orgCode, uid1, 0, '0', ''), + }); + console.log(` stateUsers -> HTTP ${r2.status} code=${r2.json.code} ${r2.json.code === 0 ? '✅' : '❌'}`); + d1 = await detail(uid1); + console.log(` 验证: test1 state=${d1.state} ${d1.state === 0 ? '✅ 已停用' : '⚠️'}`); + + // 恢复 test1 + await post('/v1/stateUsers', { + userIds: [uid1], type: 0, state: '1', + orgCode, appkey, + sign: sign(appkey, orgCode, uid1, 0, '1', ''), + }); + + // ═══ 场景3: test1 开启设备管控 (isMdm=1) ═══ + console.log('\n═══ 场景3: test1 开启设备管控 (isMdm=1) ═══'); + console.log(' 调用 updUser 设置 isMdm=1 ...'); + const r3 = await post('/v1/updUser', { + userId: uid1, + userName: '测试1', + loginName: 'test1', + deptId: '1', + isMdm: 1, + orgCode, appkey, + sign: sign(appkey, orgCode, uid1, '测试1', 'test1', '1'), + }); + console.log(` updUser -> HTTP ${r3.status} code=${r3.json.code} ${r3.json.code === 0 ? '✅' : '❌ ' + r3.json.msg}`); + d1 = await detail(uid1); + console.log(` 验证: test1 isMdm=${d1.isMdm} ${d1.isMdm === 1 ? '✅ 设备管控已开启' : '⚠️'}`); + console.log(` test1 state=${d1.state} (应仍为1-启用)`); + + console.log('\n=== 全部场景测试完成 ==='); +})(); From 4401dcc88fd2fe0daa25729993d7a73555a4c7f5 Mon Sep 17 00:00:00 2001 From: "tongzhou.li" Date: Thu, 2 Jul 2026 01:24:19 +0800 Subject: [PATCH 2/9] Align MBS service package metadata Signed-off-by: tongzhou.li --- services/zhizhangyi__mbs/package.json | 2 +- services/zhizhangyi__mbs/secret.schema.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/services/zhizhangyi__mbs/package.json b/services/zhizhangyi__mbs/package.json index b9df352f..379063ac 100644 --- a/services/zhizhangyi__mbs/package.json +++ b/services/zhizhangyi__mbs/package.json @@ -15,6 +15,6 @@ "zhizhangyi-mbs": "bin/zhizhangyi-mbs.js" }, "dependencies": { - "@chaitin-ai/octobus-sdk": "^0.5.0" + "@chaitin-ai/octobus-sdk": "^0.6.0" } } diff --git a/services/zhizhangyi__mbs/secret.schema.json b/services/zhizhangyi__mbs/secret.schema.json index 8e621f3c..444f7b8e 100644 --- a/services/zhizhangyi__mbs/secret.schema.json +++ b/services/zhizhangyi__mbs/secret.schema.json @@ -2,6 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": true, + "required": ["appkey", "secretkey", "orgCode"], "properties": { "appkey": { "type": "string", From 2720cf8092f93a8a1bbbf0d8dd3f937d2d491e78 Mon Sep 17 00:00:00 2001 From: "tongzhou.li" Date: Thu, 2 Jul 2026 01:35:10 +0800 Subject: [PATCH 3/9] Validate MBS user id mutations Signed-off-by: tongzhou.li --- .../zhizhangyi__mbs/src/zhizhangyi-mbs.js | 2 + .../test/zhizhangyi-mbs.test.js | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js index c14be4cd..83b09036 100644 --- a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js +++ b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js @@ -131,6 +131,7 @@ export function rpcdef(ctx) { const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; const tp = Number(first(req?.type) ?? 0); const uids = arr(first(req?.user_ids, req?.userIds)); const did = first(req?.condition?.dept_id, req?.condition?.deptId) || ''; + if (tp === 0 && uids.length === 0) throw er('INVALID_ARGUMENT', 'user_ids required for type=0'); const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, did); const body = { type: tp, orgCode: oc, appkey: apk, sign: sg }; if (tp === 0) { body.userIds = uids; } else { const c = buildCond(req?.condition); if (Object.keys(c).length > 0) body.condition = c; } @@ -145,6 +146,7 @@ export function rpcdef(ctx) { if (st === undefined || st === null) throw er('INVALID_ARGUMENT', 'state required'); const uids = arr(first(req?.user_ids, req?.userIds)); const did = first(req?.condition?.dept_id, req?.condition?.deptId) || ''; + if (tp === 0 && uids.length === 0) throw er('INVALID_ARGUMENT', 'user_ids required for type=0'); const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, st, did); const body = { type: tp, state: st, orgCode: oc, appkey: apk, sign: sg }; if (tp === 0) { body.userIds = uids; } else { const c = buildCond(req?.condition); if (Object.keys(c).length > 0) body.condition = c; } diff --git a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js index f62300c5..653a92ac 100644 --- a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js +++ b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js @@ -185,6 +185,48 @@ test('StateUsers requires explicit state before calling upstream', async () => { assert.equal(called, false); }); +test('DelUsers requires user_ids for type zero before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ type: 0, user_ids: [] }))[DEL_USERS](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /user_ids required for type=0/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('StateUsers requires user_ids for type zero before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ type: 0, state: 1, user_ids: [] }))[STATE_USERS](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /user_ids required for type=0/); + return true; + }, + ); + + assert.equal(called, false); +}); + test('DelUsers treats string type zero as userIds mode', async () => { let captured; globalThis.fetch = async (url, init) => { From 124a66d40764f96eb2a4c00dfd9269c2c7a49840 Mon Sep 17 00:00:00 2001 From: "tongzhou.li" Date: Thu, 2 Jul 2026 01:50:17 +0800 Subject: [PATCH 4/9] Refine MBS mutation request signing Signed-off-by: tongzhou.li --- .../zhizhangyi__mbs/src/zhizhangyi-mbs.js | 19 +++++----- .../test/zhizhangyi-mbs.test.js | 38 ++++++++++++++++--- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js index 83b09036..34bbcacf 100644 --- a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js +++ b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js @@ -105,10 +105,11 @@ export function rpcdef(ctx) { const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; const uid = first(req?.user_id, req?.userId) || ''; const un = first(req?.user_name, req?.userName) || ''; if (!uid) throw er('INVALID_ARGUMENT', 'user_id required'); if (!un) throw er('INVALID_ARGUMENT', 'user_name required'); - const ln = first(req?.login_name, req?.loginName) || ''; const did = first(req?.dept_id, req?.deptId) || ''; + const ln = str(first(req?.login_name, req?.loginName)); const did = first(req?.dept_id, req?.deptId) || ''; if (!did) throw er('INVALID_ARGUMENT', 'dept_id required'); - const sg = first(req?.sign) || signCalc(sk, apk, oc, uid, un, ln, did); - const body = { userId: uid, userName: un, loginName: ln, deptId: did, orgCode: oc, appkey: apk, sign: sg }; + const sg = first(req?.sign) || signCalc(sk, apk, oc, uid, un, ln ?? '', did); + const body = { userId: uid, userName: un, deptId: did, orgCode: oc, appkey: apk, sign: sg }; + if (ln !== undefined) body.loginName = ln; const sm = { phone_number: 'phoneNumber', job: 'job', employee_number: 'employeeNumber', address: 'address', mobile: 'mobile', email: 'email', organization: 'organization' }; for (const [k, jk] of Object.entries(sm)) { const v = str(first(req?.[k])); if (v !== undefined) body[jk] = v; } for (const k of ['is_mdm', 'weight']) { const v = num(first(req?.[k])); if (v !== undefined) body[k === 'is_mdm' ? 'isMdm' : k] = v; } @@ -130,11 +131,11 @@ export function rpcdef(ctx) { const goDelUsers = async (req) => { const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; const tp = Number(first(req?.type) ?? 0); const uids = arr(first(req?.user_ids, req?.userIds)); - const did = first(req?.condition?.dept_id, req?.condition?.deptId) || ''; + const c = buildCond(req?.condition); if (tp === 0 && uids.length === 0) throw er('INVALID_ARGUMENT', 'user_ids required for type=0'); - const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, did); + const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, c.keyWord ?? '', c.status ?? '', c.isMdm ?? '', c.deptId ?? ''); const body = { type: tp, orgCode: oc, appkey: apk, sign: sg }; - if (tp === 0) { body.userIds = uids; } else { const c = buildCond(req?.condition); if (Object.keys(c).length > 0) body.condition = c; } + if (tp === 0) { body.userIds = uids; } else if (Object.keys(c).length > 0) body.condition = c; const j = await post(BASE + '/v1/delUsers', body); return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; }; @@ -145,11 +146,11 @@ export function rpcdef(ctx) { const tp = Number(first(req?.type) ?? 0); const st = first(req?.state); if (st === undefined || st === null) throw er('INVALID_ARGUMENT', 'state required'); const uids = arr(first(req?.user_ids, req?.userIds)); - const did = first(req?.condition?.dept_id, req?.condition?.deptId) || ''; + const c = buildCond(req?.condition); if (tp === 0 && uids.length === 0) throw er('INVALID_ARGUMENT', 'user_ids required for type=0'); - const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, st, did); + const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, st, c.keyWord ?? '', c.status ?? '', c.isMdm ?? '', c.deptId ?? ''); const body = { type: tp, state: st, orgCode: oc, appkey: apk, sign: sg }; - if (tp === 0) { body.userIds = uids; } else { const c = buildCond(req?.condition); if (Object.keys(c).length > 0) body.condition = c; } + if (tp === 0) { body.userIds = uids; } else if (Object.keys(c).length > 0) body.condition = c; const j = await post(BASE + '/v1/stateUsers', body); return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; }; diff --git a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js index 653a92ac..709767ae 100644 --- a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js +++ b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; @@ -20,6 +21,11 @@ const buildCtx = (req) => ({ req, }); +const signCalc = (secret, ...params) => crypto + .createHash('md5') + .update(params.map((param) => (param === undefined || param === null ? '' : String(param))).join('') + secret, 'utf8') + .digest('hex'); + test.afterEach(() => { globalThis.fetch = originalFetch; }); @@ -150,6 +156,20 @@ test('UpdUser requires dept_id before calling upstream', async () => { assert.equal(called, false); }); +test('UpdUser omits missing loginName instead of sending empty string', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{}}' }; + }; + + await rpcdef(buildCtx({ user_id: 'user-1', user_name: 'User', dept_id: 'dept' }))[UPD_USER](); + + const body = JSON.parse(captured.init.body); + assert.equal(Object.hasOwn(body, 'loginName'), false); + assert.equal(body.sign, signCalc('secretkey', 'appkey', 'org', 'user-1', 'User', '', 'dept')); +}); + test('UpdUser omits empty numeric fields instead of sending zero values', async () => { let captured; globalThis.fetch = async (url, init) => { @@ -249,15 +269,17 @@ test('DelUsers condition mode preserves falsy condition filters', async () => { return { ok: true, status: 200, text: async () => '{"code":0,"data":null}' }; }; - const result = await rpcdef(buildCtx({ type: 1, condition: { key_word: '', status: 0, is_mdm: 0, dept_id: 'dept' } }))[DEL_USERS](); + const result = await rpcdef(buildCtx({ type: 1, condition: { key_word: 'kw', status: 0, is_mdm: 0, dept_id: 'dept' } }))[DEL_USERS](); assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/delUsers'); - assert.deepEqual(JSON.parse(captured.init.body).condition, { - keyWord: '', + const body = JSON.parse(captured.init.body); + assert.deepEqual(body.condition, { + keyWord: 'kw', status: 0, isMdm: 0, deptId: 'dept', }); + assert.equal(body.sign, signCalc('secretkey', 'appkey', 'org', '', 1, 'kw', 0, 0, 'dept')); assert.deepEqual(result.data, { nullValue: 'NULL_VALUE' }); }); @@ -268,14 +290,18 @@ test('StateUsers condition mode preserves falsy condition filters', async () => return { ok: true, status: 200, text: async () => '{"code":0,"data":{"updated":2}}' }; }; - const result = await rpcdef(buildCtx({ type: 1, state: '0', condition: { status: 0, is_mdm: 0 } }))[STATE_USERS](); + const result = await rpcdef(buildCtx({ type: 1, state: '0', condition: { key_word: 'kw', status: 0, is_mdm: 0, dept_id: 'dept' } }))[STATE_USERS](); assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/stateUsers'); - assert.equal(JSON.parse(captured.init.body).state, '0'); - assert.deepEqual(JSON.parse(captured.init.body).condition, { + const body = JSON.parse(captured.init.body); + assert.equal(body.state, '0'); + assert.deepEqual(body.condition, { + keyWord: 'kw', status: 0, isMdm: 0, + deptId: 'dept', }); + assert.equal(body.sign, signCalc('secretkey', 'appkey', 'org', '', 1, '0', 'kw', 0, 0, 'dept')); assert.equal(result.data.structValue.fields.updated.numberValue, 2); }); From cca9a7ad58e59a0ba5344a1a0de516884ab6b14e Mon Sep 17 00:00:00 2001 From: "tongzhou.li" Date: Thu, 2 Jul 2026 02:06:16 +0800 Subject: [PATCH 5/9] Require MBS condition mutations filters Signed-off-by: tongzhou.li --- .../zhizhangyi__mbs/src/zhizhangyi-mbs.js | 2 + .../test/zhizhangyi-mbs.test.js | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js index 34bbcacf..0558acaa 100644 --- a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js +++ b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js @@ -133,6 +133,7 @@ export function rpcdef(ctx) { const tp = Number(first(req?.type) ?? 0); const uids = arr(first(req?.user_ids, req?.userIds)); const c = buildCond(req?.condition); if (tp === 0 && uids.length === 0) throw er('INVALID_ARGUMENT', 'user_ids required for type=0'); + if (tp === 1 && Object.keys(c).length === 0) throw er('INVALID_ARGUMENT', 'condition required for type=1'); const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, c.keyWord ?? '', c.status ?? '', c.isMdm ?? '', c.deptId ?? ''); const body = { type: tp, orgCode: oc, appkey: apk, sign: sg }; if (tp === 0) { body.userIds = uids; } else if (Object.keys(c).length > 0) body.condition = c; @@ -148,6 +149,7 @@ export function rpcdef(ctx) { const uids = arr(first(req?.user_ids, req?.userIds)); const c = buildCond(req?.condition); if (tp === 0 && uids.length === 0) throw er('INVALID_ARGUMENT', 'user_ids required for type=0'); + if (tp === 1 && Object.keys(c).length === 0) throw er('INVALID_ARGUMENT', 'condition required for type=1'); const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, st, c.keyWord ?? '', c.status ?? '', c.isMdm ?? '', c.deptId ?? ''); const body = { type: tp, state: st, orgCode: oc, appkey: apk, sign: sg }; if (tp === 0) { body.userIds = uids; } else if (Object.keys(c).length > 0) body.condition = c; diff --git a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js index 709767ae..04c0fdb3 100644 --- a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js +++ b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js @@ -262,6 +262,48 @@ test('DelUsers treats string type zero as userIds mode', async () => { assert.equal(Object.hasOwn(JSON.parse(captured.init.body), 'condition'), false); }); +test('DelUsers requires condition for type one before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ type: 1 }))[DEL_USERS](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /condition required for type=1/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('StateUsers requires condition for type one before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ type: 1, state: 1, condition: {} }))[STATE_USERS](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /condition required for type=1/); + return true; + }, + ); + + assert.equal(called, false); +}); + test('DelUsers condition mode preserves falsy condition filters', async () => { let captured; globalThis.fetch = async (url, init) => { From 792b649fe0981746d161559f91da74cffda97d82 Mon Sep 17 00:00:00 2001 From: "tongzhou.li" Date: Thu, 2 Jul 2026 02:19:40 +0800 Subject: [PATCH 6/9] Cover remaining MBS handlers Signed-off-by: tongzhou.li --- .../test/zhizhangyi-mbs.test.js | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js index 04c0fdb3..a899bc7d 100644 --- a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js +++ b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js @@ -7,8 +7,13 @@ import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; import { rpcdef } from '../src/zhizhangyi-mbs.js'; const ADD_USER = 'zhizhangyi.mbs.UserManagement/AddUser'; +const CHECK_LOGIN_NAME = 'zhizhangyi.mbs.UserManagement/CheckLoginName'; const DEL_USERS = 'zhizhangyi.mbs.UserManagement/DelUsers'; +const DETAIL_USER = 'zhizhangyi.mbs.UserManagement/DetailUser'; +const FORCE_OFFLINE = 'zhizhangyi.mbs.UserManagement/ForceOffline'; +const GET_USER_BY_PHONE = 'zhizhangyi.mbs.UserManagement/GetUserByPhone'; const GET_USERS = 'zhizhangyi.mbs.UserManagement/GetUsers'; +const IMPORT_USER = 'zhizhangyi.mbs.UserManagement/ImportUser'; const STATE_USERS = 'zhizhangyi.mbs.UserManagement/StateUsers'; const UPD_USER = 'zhizhangyi.mbs.UserManagement/UpdUser'; const UPD_USER_PWD = 'zhizhangyi.mbs.UserManagement/UpdUserPwd'; @@ -472,3 +477,197 @@ test('StateUsers treats string type zero as userIds mode', async () => { assert.equal(JSON.parse(captured.init.body).state, '1'); assert.equal(Object.hasOwn(JSON.parse(captured.init.body), 'condition'), false); }); + +test('DetailUser requires user_id before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({}))[DETAIL_USER](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /user_id required/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('DetailUser maps detail response and returns null data when upstream data is empty', async () => { + const responses = [ + { code: 0, data: { userId: 'user-1', userName: 'User', loginName: 'login', deptId: 'dept', deptName: 'Dept', phoneNumber: '13800000000', job: 'dev', employeeNumber: 'E001', address: 'addr', mobile: '13900000000', email: 'u@example.com', organization: 'org-name', isMdm: 0, state: 1, weight: 2, iconFileId: 'icon', attrs: [{ attrKey: 'role', attrValue: 'admin' }] } }, + { code: 0, data: null }, + ]; + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url, init }); + return { ok: true, status: 200, text: async () => JSON.stringify(responses.shift()) }; + }; + + const detail = await rpcdef(buildCtx({ user_id: 'user-1' }))[DETAIL_USER](); + const empty = await rpcdef(buildCtx({ user_id: 'user-2' }))[DETAIL_USER](); + + assert.equal(calls[0].url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/detailUser'); + assert.equal(JSON.parse(calls[0].init.body).userId, 'user-1'); + assert.equal(JSON.parse(calls[0].init.body).sign, signCalc('secretkey', 'appkey', 'org', 'user-1')); + assert.deepEqual(detail.data, { + user_id: 'user-1', + user_name: 'User', + login_name: 'login', + dept_id: 'dept', + dept_name: 'Dept', + phone_number: '13800000000', + job: 'dev', + employee_number: 'E001', + address: 'addr', + mobile: '13900000000', + email: 'u@example.com', + organization: 'org-name', + is_mdm: 0, + state: 1, + weight: 2, + icon_file_id: 'icon', + attrs: [{ attr_key: 'role', attr_value: 'admin' }], + }); + assert.equal(empty.data, null); +}); + +test('CheckLoginName requires login_name before calling upstream and signs request', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({}))[CHECK_LOGIN_NAME](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /login_name required/); + return true; + }, + ); + assert.equal(called, false); + + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"available":true}}' }; + }; + + const result = await rpcdef(buildCtx({ login_name: 'user' }))[CHECK_LOGIN_NAME](); + const body = JSON.parse(captured.init.body); + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/checkLoginName'); + assert.equal(body.loginName, 'user'); + assert.equal(body.sign, signCalc('secretkey', 'appkey', 'org', 'user')); + assert.equal(result.data.structValue.fields.available.boolValue, true); +}); + +test('GetUserByPhone requires phone and maps array or null data', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({}))[GET_USER_BY_PHONE](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /phone required/); + return true; + }, + ); + assert.equal(called, false); + + const responses = [ + { code: 0, data: [{ userId: 'user-1', userName: 'User', loginName: 'login', phoneNumber: '13800000000', email: 'u@example.com' }] }, + { code: 0, data: null }, + ]; + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url, init }); + return { ok: true, status: 200, text: async () => JSON.stringify(responses.shift()) }; + }; + + const result = await rpcdef(buildCtx({ phone: '13800000000' }))[GET_USER_BY_PHONE](); + const empty = await rpcdef(buildCtx({ phone: '13900000000' }))[GET_USER_BY_PHONE](); + const body = JSON.parse(calls[0].init.body); + assert.equal(calls[0].url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/getUserByPhone'); + assert.equal(body.phone, '13800000000'); + assert.equal(body.sign, signCalc('secretkey', 'appkey', 'org', '13800000000')); + assert.deepEqual(result.data, [{ user_id: 'user-1', user_name: 'User', login_name: 'login', phone_number: '13800000000', email: 'u@example.com' }]); + assert.deepEqual(empty.data, []); +}); + +test('ForceOffline requires user_id before calling upstream and signs request', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({}))[FORCE_OFFLINE](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /user_id required/); + return true; + }, + ); + assert.equal(called, false); + + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"offline":true}}' }; + }; + + const result = await rpcdef(buildCtx({ user_id: 'user-1' }))[FORCE_OFFLINE](); + const body = JSON.parse(captured.init.body); + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/forceOffline'); + assert.equal(body.userId, 'user-1'); + assert.equal(body.sign, signCalc('secretkey', 'appkey', 'org', 'user-1')); + assert.equal(result.data.structValue.fields.offline.boolValue, true); +}); + +test('ImportUser requires file_id and posts default lang with signature', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({}))[IMPORT_USER](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /file_id required/); + return true; + }, + ); + assert.equal(called, false); + + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"imported":3}}' }; + }; + + const result = await rpcdef(buildCtx({ file_id: 'file-1' }))[IMPORT_USER](); + const body = JSON.parse(captured.init.body); + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/importUser'); + assert.equal(body.lang, 0); + assert.equal(body.fileId, 'file-1'); + assert.equal(body.sign, signCalc('secretkey', 'appkey', 'org', 0, 'file-1')); + assert.equal(result.data.structValue.fields.imported.numberValue, 3); +}); From 43dcb425ec2c9df47db99c032f85600555a1152b Mon Sep 17 00:00:00 2001 From: "tongzhou.li" Date: Thu, 2 Jul 2026 02:32:47 +0800 Subject: [PATCH 7/9] Align MBS batch mutation signatures Signed-off-by: tongzhou.li --- services/zhizhangyi__mbs/src/zhizhangyi-mbs.js | 10 ++++++++-- .../test/zhizhangyi-mbs.test.js | 18 +++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js index 0558acaa..2b996240 100644 --- a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js +++ b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js @@ -134,7 +134,10 @@ export function rpcdef(ctx) { const c = buildCond(req?.condition); if (tp === 0 && uids.length === 0) throw er('INVALID_ARGUMENT', 'user_ids required for type=0'); if (tp === 1 && Object.keys(c).length === 0) throw er('INVALID_ARGUMENT', 'condition required for type=1'); - const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, c.keyWord ?? '', c.status ?? '', c.isMdm ?? '', c.deptId ?? ''); + const signParams = tp === 0 + ? [uids.join(','), tp] + : [uids.join(','), tp, c.keyWord ?? '', c.status ?? '', c.isMdm ?? '', c.deptId ?? '']; + const sg = first(req?.sign) || signCalc(sk, apk, oc, ...signParams); const body = { type: tp, orgCode: oc, appkey: apk, sign: sg }; if (tp === 0) { body.userIds = uids; } else if (Object.keys(c).length > 0) body.condition = c; const j = await post(BASE + '/v1/delUsers', body); @@ -150,7 +153,10 @@ export function rpcdef(ctx) { const c = buildCond(req?.condition); if (tp === 0 && uids.length === 0) throw er('INVALID_ARGUMENT', 'user_ids required for type=0'); if (tp === 1 && Object.keys(c).length === 0) throw er('INVALID_ARGUMENT', 'condition required for type=1'); - const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, st, c.keyWord ?? '', c.status ?? '', c.isMdm ?? '', c.deptId ?? ''); + const signParams = tp === 0 + ? [uids.join(','), tp, st, ''] + : [uids.join(','), tp, st, c.keyWord ?? '', c.status ?? '', c.isMdm ?? '', c.deptId ?? '']; + const sg = first(req?.sign) || signCalc(sk, apk, oc, ...signParams); const body = { type: tp, state: st, orgCode: oc, appkey: apk, sign: sg }; if (tp === 0) { body.userIds = uids; } else if (Object.keys(c).length > 0) body.condition = c; const j = await post(BASE + '/v1/stateUsers', body); diff --git a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js index a899bc7d..05faff17 100644 --- a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js +++ b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js @@ -262,9 +262,11 @@ test('DelUsers treats string type zero as userIds mode', async () => { await rpcdef(buildCtx({ type: '0', user_ids: ['user-1'] }))[DEL_USERS](); assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/delUsers'); - assert.deepEqual(JSON.parse(captured.init.body).userIds, ['user-1']); - assert.equal(JSON.parse(captured.init.body).type, 0); - assert.equal(Object.hasOwn(JSON.parse(captured.init.body), 'condition'), false); + const body = JSON.parse(captured.init.body); + assert.deepEqual(body.userIds, ['user-1']); + assert.equal(body.type, 0); + assert.equal(body.sign, signCalc('secretkey', 'appkey', 'org', 'user-1', 0)); + assert.equal(Object.hasOwn(body, 'condition'), false); }); test('DelUsers requires condition for type one before calling upstream', async () => { @@ -472,10 +474,12 @@ test('StateUsers treats string type zero as userIds mode', async () => { await rpcdef(buildCtx({ type: '0', state: '1', user_ids: ['user-1'] }))[STATE_USERS](); assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/stateUsers'); - assert.deepEqual(JSON.parse(captured.init.body).userIds, ['user-1']); - assert.equal(JSON.parse(captured.init.body).type, 0); - assert.equal(JSON.parse(captured.init.body).state, '1'); - assert.equal(Object.hasOwn(JSON.parse(captured.init.body), 'condition'), false); + const body = JSON.parse(captured.init.body); + assert.deepEqual(body.userIds, ['user-1']); + assert.equal(body.type, 0); + assert.equal(body.state, '1'); + assert.equal(body.sign, signCalc('secretkey', 'appkey', 'org', 'user-1', 0, '1', '')); + assert.equal(Object.hasOwn(body, 'condition'), false); }); test('DetailUser requires user_id before calling upstream', async () => { From 1d592713072d7744242f5e1dca5f147a34a01e7f Mon Sep 17 00:00:00 2001 From: "tongzhou.li" Date: Thu, 2 Jul 2026 12:05:57 +0800 Subject: [PATCH 8/9] Harden MBS fetch and type validation Signed-off-by: tongzhou.li --- services/zhizhangyi__mbs/package.json | 3 +- .../zhizhangyi__mbs/src/zhizhangyi-mbs.js | 9 ++- .../test/zhizhangyi-mbs.test.js | 65 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/services/zhizhangyi__mbs/package.json b/services/zhizhangyi__mbs/package.json index 379063ac..9f4794e3 100644 --- a/services/zhizhangyi__mbs/package.json +++ b/services/zhizhangyi__mbs/package.json @@ -15,6 +15,7 @@ "zhizhangyi-mbs": "bin/zhizhangyi-mbs.js" }, "dependencies": { - "@chaitin-ai/octobus-sdk": "^0.6.0" + "@chaitin-ai/octobus-sdk": "^0.6.0", + "undici": "^7.16.0" } } diff --git a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js index 2b996240..d3357100 100644 --- a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js +++ b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js @@ -1,6 +1,7 @@ // MBS User Management API Proxy - Part 1: helpers + first 6 handlers import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; import crypto from 'node:crypto'; +import { Agent } from 'undici'; const DEF_TO = 30000; const BASE = '/uusafe/mos/thirdaccess/rest/opt'; @@ -16,7 +17,11 @@ const str = (v) => (v === undefined || v === null || v === '') ? undefined : Str const num = (v) => (v === undefined || v === null || v === '') ? undefined : Number(v); const gc = (c) => ({ INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, UNAVAILABLE: grpcStatus.UNAVAILABLE, DEADLINE_EXCEEDED: grpcStatus.DEADLINE_EXCEEDED })[c] ?? grpcStatus.UNKNOWN; const er = (c, m) => { const e = new GrpcError(gc(c), c + ': ' + m); e.legacyCode = c; return e; }; -const doFetch = async (url, init, to, st) => { const tls = st ? { insecureSkipVerify: true, tlsInsecureSkipVerify: true } : {}; try { return await fetch(url, { ...init, timeoutMs: to, ...tls }); } catch (e) { throw er('UNAVAILABLE', e?.cause?.message || e?.message || 'fetch failed'); } }; +const doFetch = async (url, init, to, st) => { + const dispatcher = st ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined; + const signal = to ? AbortSignal.timeout(Number(to)) : undefined; + try { return await fetch(url, { ...init, ...(dispatcher && { dispatcher }), ...(signal && { signal }) }); } catch (e) { throw er('UNAVAILABLE', e?.cause?.message || e?.message || 'fetch failed'); } +}; const rdJson = async (res) => { const t = await res.text(); if (!res.ok) { const safe = t.length > 200 ? t.slice(0, 200) + '...' : t; throw er(res.status === 401 || res.status === 403 ? 'PERMISSION_DENIED' : res.status >= 400 && res.status < 500 ? 'FAILED_PRECONDITION' : 'UNAVAILABLE', 'http ' + res.status + ': ' + safe); } if (!t.trim()) return {}; try { return JSON.parse(t); } catch { throw er('UNKNOWN', 'not JSON'); } }; const check = (j) => { if (j.code !== undefined && j.code !== 0) throw er('FAILED_PRECONDITION', 'MBS code=' + j.code + ': ' + (j.msg || '')); }; const buildCond = (cond) => { const c = {}; const kw = first(cond?.key_word, cond?.keyWord); if (kw !== undefined) c.keyWord = kw; if (cond?.status !== undefined && cond?.status !== null) c.status = cond.status; const im = first(cond?.is_mdm, cond?.isMdm); if (im !== undefined) c.isMdm = im; const did = first(cond?.dept_id, cond?.deptId); if (did !== undefined) c.deptId = did; return c; }; @@ -131,6 +136,7 @@ export function rpcdef(ctx) { const goDelUsers = async (req) => { const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; const tp = Number(first(req?.type) ?? 0); const uids = arr(first(req?.user_ids, req?.userIds)); + if (tp !== 0 && tp !== 1) throw er('INVALID_ARGUMENT', 'type must be 0 or 1'); const c = buildCond(req?.condition); if (tp === 0 && uids.length === 0) throw er('INVALID_ARGUMENT', 'user_ids required for type=0'); if (tp === 1 && Object.keys(c).length === 0) throw er('INVALID_ARGUMENT', 'condition required for type=1'); @@ -148,6 +154,7 @@ export function rpcdef(ctx) { const goStateUsers = async (req) => { const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; const tp = Number(first(req?.type) ?? 0); const st = first(req?.state); + if (tp !== 0 && tp !== 1) throw er('INVALID_ARGUMENT', 'type must be 0 or 1'); if (st === undefined || st === null) throw er('INVALID_ARGUMENT', 'state required'); const uids = arr(first(req?.user_ids, req?.userIds)); const c = buildCond(req?.condition); diff --git a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js index 05faff17..3865c8fe 100644 --- a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js +++ b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js @@ -74,6 +74,29 @@ test('GetUsers preserves falsy state and is_mdm filters', async () => { }); }); +test('fetch uses AbortSignal and undici dispatcher for timeout and TLS skip', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"total":0,"userInfos":[]}}' }; + }; + + await rpcdef({ + config: { endpoint: 'https://mbs.example', skipTlsVerify: true }, + secret: { appkey: 'appkey', secretkey: 'secretkey', orgCode: 'org' }, + limits: { timeoutMs: 1234 }, + req: { condition: { dept_id: '1' } }, + })[GET_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/getUsers'); + assert.ok(captured.init.signal instanceof AbortSignal); + assert.equal(captured.init.signal.aborted, false); + assert.ok(captured.init.dispatcher); + assert.equal(Object.hasOwn(captured.init, 'timeoutMs'), false); + assert.equal(Object.hasOwn(captured.init, 'insecureSkipVerify'), false); + assert.equal(Object.hasOwn(captured.init, 'tlsInsecureSkipVerify'), false); +}); + test('GetUsers requires dept_id before calling upstream', async () => { let called = false; globalThis.fetch = async () => { @@ -269,6 +292,27 @@ test('DelUsers treats string type zero as userIds mode', async () => { assert.equal(Object.hasOwn(body, 'condition'), false); }); +test('DelUsers rejects invalid type before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ type: 2, user_ids: ['user-1'] }))[DEL_USERS](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /type must be 0 or 1/); + return true; + }, + ); + + assert.equal(called, false); +}); + test('DelUsers requires condition for type one before calling upstream', async () => { let called = false; globalThis.fetch = async () => { @@ -290,6 +334,27 @@ test('DelUsers requires condition for type one before calling upstream', async ( assert.equal(called, false); }); +test('StateUsers rejects invalid type before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ type: 'bad', state: 1, user_ids: ['user-1'] }))[STATE_USERS](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /type must be 0 or 1/); + return true; + }, + ); + + assert.equal(called, false); +}); + test('StateUsers requires condition for type one before calling upstream', async () => { let called = false; globalThis.fetch = async () => { From 609feba8043d92a3c30d9df9a8f86d1b2920e90b Mon Sep 17 00:00:00 2001 From: "tongzhou.li" Date: Thu, 2 Jul 2026 14:20:55 +0800 Subject: [PATCH 9/9] Refine Zhizhangyi MBS numeric and fetch handling --- .../zhizhangyi__mbs/src/zhizhangyi-mbs.js | 16 +++++++++---- .../test/zhizhangyi-mbs.test.js | 24 +++++++++++++++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js index d3357100..0f8c9877 100644 --- a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js +++ b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js @@ -5,6 +5,7 @@ import { Agent } from 'undici'; const DEF_TO = 30000; const BASE = '/uusafe/mos/thirdaccess/rest/opt'; +const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false } }); const has = (o, k) => Object.prototype.hasOwnProperty.call(o ?? {}, k); const first = (...vs) => vs.find((v) => v !== undefined && v !== null); @@ -14,13 +15,16 @@ const md5 = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex'); const signCalc = (sk, ...ps) => md5(ps.map((p) => (p === undefined || p === null ? '' : String(p))).join('') + String(sk || '')); const arr = (v) => Array.isArray(v) ? v : []; const str = (v) => (v === undefined || v === null || v === '') ? undefined : String(v); -const num = (v) => (v === undefined || v === null || v === '') ? undefined : Number(v); +const num = (v) => { + if (v === undefined || v === null || v === '') return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +}; const gc = (c) => ({ INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, UNAVAILABLE: grpcStatus.UNAVAILABLE, DEADLINE_EXCEEDED: grpcStatus.DEADLINE_EXCEEDED })[c] ?? grpcStatus.UNKNOWN; const er = (c, m) => { const e = new GrpcError(gc(c), c + ': ' + m); e.legacyCode = c; return e; }; const doFetch = async (url, init, to, st) => { - const dispatcher = st ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined; const signal = to ? AbortSignal.timeout(Number(to)) : undefined; - try { return await fetch(url, { ...init, ...(dispatcher && { dispatcher }), ...(signal && { signal }) }); } catch (e) { throw er('UNAVAILABLE', e?.cause?.message || e?.message || 'fetch failed'); } + try { return await fetch(url, { ...init, ...(st && { dispatcher: insecureDispatcher }), ...(signal && { signal }) }); } catch (e) { throw er('UNAVAILABLE', e?.cause?.message || e?.message || 'fetch failed'); } }; const rdJson = async (res) => { const t = await res.text(); if (!res.ok) { const safe = t.length > 200 ? t.slice(0, 200) + '...' : t; throw er(res.status === 401 || res.status === 403 ? 'PERMISSION_DENIED' : res.status >= 400 && res.status < 500 ? 'FAILED_PRECONDITION' : 'UNAVAILABLE', 'http ' + res.status + ': ' + safe); } if (!t.trim()) return {}; try { return JSON.parse(t); } catch { throw er('UNKNOWN', 'not JSON'); } }; const check = (j) => { if (j.code !== undefined && j.code !== 0) throw er('FAILED_PRECONDITION', 'MBS code=' + j.code + ': ' + (j.msg || '')); }; @@ -39,7 +43,7 @@ const toValue = (value) => { return { stringValue: String(value) }; }; -const mapUser = (it) => ({ user_id: it?.userId ?? '', user_name: it?.userName ?? '', login_name: it?.loginName ?? '', phone_number: it?.phoneNumber ?? '', email: it?.email ?? '', employee_number: it?.employeeNumber ?? '', dept_id: it?.deptId ?? '', dept_name: it?.deptName ?? '', device_count: it?.deviceCount ?? 0, is_mdm: it?.isMdm ?? 0, state: it?.state ?? 0, is_admin: it?.isAdmin ?? 0, user_source: it?.userSource ?? 0, status: it?.status ?? 1, weight: it?.weight ?? 0, attrs: arr(it?.attrs).map((a) => ({ attr_key: a?.attrKey ?? '', attr_value: a?.attrValue ?? '' })), dept_full_id: it?.deptFullId ?? '', dept_full_path: it?.deptFullPath ?? '', job: it?.job ?? '', mobile: it?.mobile ?? '', address: it?.address ?? '', organization: it?.organization ?? '' }); +const mapUser = (it) => ({ user_id: it?.userId ?? '', user_name: it?.userName ?? '', login_name: it?.loginName ?? '', phone_number: it?.phoneNumber ?? '', email: it?.email ?? '', employee_number: it?.employeeNumber ?? '', dept_id: it?.deptId ?? '', dept_name: it?.deptName ?? '', device_count: it?.deviceCount ?? 0, is_mdm: it?.isMdm ?? 0, state: it?.state ?? 0, is_admin: it?.isAdmin ?? 0, user_source: it?.userSource ?? 0, status: it?.status ?? 0, weight: it?.weight ?? 0, attrs: arr(it?.attrs).map((a) => ({ attr_key: a?.attrKey ?? '', attr_value: a?.attrValue ?? '' })), dept_full_id: it?.deptFullId ?? '', dept_full_path: it?.deptFullPath ?? '', job: it?.job ?? '', mobile: it?.mobile ?? '', address: it?.address ?? '', organization: it?.organization ?? '' }); const mapDetail = (it) => ({ user_id: it?.userId ?? '', user_name: it?.userName ?? '', login_name: it?.loginName ?? '', dept_id: it?.deptId ?? '', dept_name: it?.deptName ?? '', phone_number: it?.phoneNumber ?? '', job: it?.job ?? '', employee_number: it?.employeeNumber ?? '', address: it?.address ?? '', mobile: it?.mobile ?? '', email: it?.email ?? '', organization: it?.organization ?? '', is_mdm: it?.isMdm ?? 0, state: it?.state ?? 0, weight: it?.weight ?? 0, icon_file_id: it?.iconFileId ?? '', attrs: arr(it?.attrs).map((a) => ({ attr_key: a?.attrKey ?? '', attr_value: a?.attrValue ?? '' })) }); const mapPhone = (it) => ({ user_id: it?.userId ?? '', user_name: it?.userName ?? '', login_name: it?.loginName ?? '', phone_number: it?.phoneNumber ?? '', email: it?.email ?? '' }); @@ -96,7 +100,9 @@ export function rpcdef(ctx) { const did = first(req?.dept_id, req?.deptId) || ''; const encryptedPw = first(req?.password) || ''; if (!un) throw er('INVALID_ARGUMENT', 'user_name required'); if (!ln) throw er('INVALID_ARGUMENT', 'login_name required'); if (!did) throw er('INVALID_ARGUMENT', 'dept_id required'); if (!encryptedPw) throw er('INVALID_ARGUMENT', 'password required as 3DES-encrypted value'); const sg = first(req?.sign) || signCalc(sk, apk, oc, un, ln, did, encryptedPw); - const body = { userName: un, loginName: ln, deptId: did, password: encryptedPw, userSource: Number(first(req?.user_source, req?.userSource) ?? 0), orgCode: oc, appkey: apk, sign: sg }; + const body = { userName: un, loginName: ln, deptId: did, password: encryptedPw, orgCode: oc, appkey: apk, sign: sg }; + const userSource = num(first(req?.user_source, req?.userSource)); + if (userSource !== undefined) body.userSource = userSource; const sm = { phone_number: 'phoneNumber', job: 'job', employee_number: 'employeeNumber', address: 'address', mobile: 'mobile', email: 'email', organization: 'organization' }; for (const [k, jk] of Object.entries(sm)) { const v = str(first(req?.[k])); if (v !== undefined) body[jk] = v; } for (const k of ['is_mdm', 'state', 'weight']) { const v = num(first(req?.[k])); if (v !== undefined) body[k === 'is_mdm' ? 'isMdm' : k] = v; } diff --git a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js index 3865c8fe..0012ab20 100644 --- a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js +++ b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js @@ -56,14 +56,14 @@ test('AddUser requires password before calling upstream', async () => { assert.equal(called, false); }); -test('GetUsers preserves falsy state and is_mdm filters', async () => { +test('GetUsers preserves falsy state, is_mdm, and status fields', async () => { let captured; globalThis.fetch = async (url, init) => { captured = { url, init }; - return { ok: true, status: 200, text: async () => '{"code":0,"data":{"total":0,"userInfos":[]}}' }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"total":1,"userInfos":[{"userId":"user-1","status":0}]}}' }; }; - await rpcdef(buildCtx({ condition: { dept_id: '1', state: 0, is_mdm: 0 } }))[GET_USERS](); + const result = await rpcdef(buildCtx({ condition: { dept_id: '1', state: 0, is_mdm: 0 } }))[GET_USERS](); assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/getUsers'); assert.deepEqual(JSON.parse(captured.init.body).condition, { @@ -72,6 +72,7 @@ test('GetUsers preserves falsy state and is_mdm filters', async () => { state: 0, isMdm: 0, }); + assert.equal(result.data.user_infos[0].status, 0); }); test('fetch uses AbortSignal and undici dispatcher for timeout and TLS skip', async () => { @@ -95,6 +96,17 @@ test('fetch uses AbortSignal and undici dispatcher for timeout and TLS skip', as assert.equal(Object.hasOwn(captured.init, 'timeoutMs'), false); assert.equal(Object.hasOwn(captured.init, 'insecureSkipVerify'), false); assert.equal(Object.hasOwn(captured.init, 'tlsInsecureSkipVerify'), false); + let second; + globalThis.fetch = async (url, init) => { + second = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"total":0,"userInfos":[]}}' }; + }; + await rpcdef({ + config: { endpoint: 'https://mbs.example', skipTlsVerify: true }, + secret: { appkey: 'appkey', secretkey: 'secretkey', orgCode: 'org' }, + req: { condition: { dept_id: '1' } }, + })[GET_USERS](); + assert.equal(second.init.dispatcher, captured.init.dispatcher); }); test('GetUsers requires dept_id before calling upstream', async () => { @@ -148,19 +160,21 @@ test('AddUser forwards caller-provided 3DES-encrypted password value', async () assert.equal(result.data.structValue.fields.created.boolValue, true); }); -test('AddUser omits empty numeric fields instead of sending zero values', async () => { +test('AddUser omits empty and invalid numeric fields instead of sending zero or null values', async () => { let captured; globalThis.fetch = async (url, init) => { captured = { url, init }; return { ok: true, status: 200, text: async () => '{"code":0,"data":{}}' }; }; - await rpcdef(buildCtx({ user_name: 'User', login_name: 'user', dept_id: 'dept', password: '3des-ciphertext', is_mdm: '', state: '', weight: '' }))[ADD_USER](); + await rpcdef(buildCtx({ user_name: 'User', login_name: 'user', dept_id: 'dept', password: '3des-ciphertext', user_source: 'abc', is_mdm: 'NaN', state: 'Infinity', weight: '' }))[ADD_USER](); const body = JSON.parse(captured.init.body); assert.equal(Object.hasOwn(body, 'isMdm'), false); assert.equal(Object.hasOwn(body, 'state'), false); assert.equal(Object.hasOwn(body, 'weight'), false); + assert.equal(Object.hasOwn(body, 'userSource'), false); + assert.equal(JSON.stringify(body).includes('null'), false); }); test('UpdUser requires dept_id before calling upstream', async () => {