@oj-module/cli 0.2.43 → 0.2.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/README.md +7 -5
- package/devkit/SKILL.md +22 -2
- package/devkit/manual.md +5 -0
- package/devkit/wecom-daikaifa.md +421 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +102 -39
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/manifest-routes-derive.test.ts +22 -0
- package/src/__tests__/site-tenant-header.test.ts +54 -0
- package/src/__tests__/templates-tenant-scope.test.ts +139 -0
- package/src/build.ts +9 -0
- package/src/init.ts +4 -1
- package/src/install.ts +2 -2
- package/src/market/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +72 -2
- package/templates/openapi.json +319 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
- package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
- package/templates/src/_platform/api/_shared/respond.ts +11 -6
- package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
- package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
- package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
- package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
- package/templates/src/_platform/api/contacts/api.ts +4 -1
- package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
- package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
- package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
- package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
- package/templates/src/_platform/api/schema.yaml +28 -0
- package/templates/src/_platform/api/seed.sql +3 -0
- package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
- package/templates/src/auth/api/_shared/wecom/config.ts +176 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +317 -0
- package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
- package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
- package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
- package/templates/src/auth/api/manifest.yaml +6 -0
- package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
- package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
- package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
- package/templates/src/auth/api/schema.yaml +48 -0
- package/templates/src/auth/api/wecom/admin/api.ts +121 -0
- package/templates/src/auth/api/wecom/api.ts +246 -0
- package/templates/src/auth/web/__client/api.schemas.ts +36 -0
- package/templates/src/auth/web/__client/api.ts +148 -1
- package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
- package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
- package/templates/src/demo/api/schema.yaml +1 -0
- package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
- package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
- package/templates/src/home/api/schema.yaml +2 -0
- package/templates/src/login/web/auth-provider.ts +48 -0
- package/templates/src/login/web/entry.ts +21 -1
- package/templates/src/login/web/locales/en-US.json +11 -1
- package/templates/src/login/web/locales/zh-CN.json +11 -1
- package/templates/src/login/web/pages/login.tsx +154 -25
- package/templates/src/login/web/wecom.ts +51 -0
- package/templates/src/notification/api/_shared/notify.ts +9 -5
- package/templates/src/notification/api/api.ts +25 -0
- package/templates/src/notification/api/del/api.ts +5 -0
- package/templates/src/notification/api/manifest.yaml +3 -0
- package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
- package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
- package/templates/src/notification/api/schema.yaml +4 -0
- package/templates/src/personal-center/api/profile/api.ts +13 -0
- package/templates/src/personal-center/api/upload/api.ts +8 -1
- package/templates/src/system/web/entry.ts +8 -0
- package/templates/src/system/web/locales/en-US.json +33 -2
- package/templates/src/system/web/locales/zh-CN.json +33 -2
- package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
- package/templates/src/system/web/pages/users/index.tsx +1 -1
- package/templates/src/system/web/pages/wecom/index.tsx +205 -0
- package/templates/tests/fixture.ts +91 -11
- package/templates/tests/guard.spec.ts +9 -3
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
- package/templates/tests/lifecycle/installer.spec.ts +76 -0
- package/templates/tests/lifecycle/trust.spec.ts +1 -1
- package/templates/tests/query-utils.spec.ts +12 -9
- package/templates/tests/rbac-guard.spec.ts +18 -1
- package/templates/tests/tenant-access-cache.spec.ts +99 -0
- package/templates/tests/tenant-forgery.spec.ts +152 -0
- package/templates/tests/tenant-isolation.spec.ts +133 -0
- package/templates/tests/username-namespace.spec.ts +118 -0
- package/templates/tests/wecom-admin.spec.ts +386 -0
- package/templates/tests/wecom-connector.spec.ts +701 -0
- package/templates/tests/wecom-crypt.spec.ts +155 -0
- package/templates/tests/wecom-endpoints.spec.ts +468 -0
- package/templates/tests/wecom-landing.spec.ts +54 -0
- package/vendor/host-versions.json +3 -3
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"roles": "角色权限",
|
|
7
7
|
"oplog": "操作日志",
|
|
8
8
|
"settings": "站点设置",
|
|
9
|
-
"loginMethods": "登录方式"
|
|
9
|
+
"loginMethods": "登录方式",
|
|
10
|
+
"wecom": "企业微信代开发"
|
|
10
11
|
},
|
|
11
12
|
"common": {
|
|
12
13
|
"confirm": "确认",
|
|
@@ -73,7 +74,36 @@
|
|
|
73
74
|
"title": "当前启用的登录方式",
|
|
74
75
|
"methodLocal": "本地口令",
|
|
75
76
|
"methodLdap": "LDAP",
|
|
76
|
-
"methodSso": "SSO 单点登录"
|
|
77
|
+
"methodSso": "SSO 单点登录",
|
|
78
|
+
"methodWecom": "企业微信"
|
|
79
|
+
},
|
|
80
|
+
"wecom": {
|
|
81
|
+
"title": "开通企业",
|
|
82
|
+
"hint": "点「生成开通链接」后把链接或二维码发给企业管理员,由其确认授权;确认后企业侧的回调会写入本表。链接 10 分钟内有效且只能用一次,过期后二维码会置灰,需重新生成。",
|
|
83
|
+
"appsTitle": "已开通企业",
|
|
84
|
+
"genInstall": "生成开通链接",
|
|
85
|
+
"installOk": "已生成,请把链接发给企业管理员",
|
|
86
|
+
"installUrl": "开通链接",
|
|
87
|
+
"callbackUrl": "回调地址",
|
|
88
|
+
"expiresIn": "有效期",
|
|
89
|
+
"expiresValue": "{{count}} 分钟",
|
|
90
|
+
"col": {
|
|
91
|
+
"corp": "企业",
|
|
92
|
+
"agent": "应用",
|
|
93
|
+
"env": "环境",
|
|
94
|
+
"status": "状态",
|
|
95
|
+
"installedAt": "开通时间",
|
|
96
|
+
"action": "操作"
|
|
97
|
+
},
|
|
98
|
+
"statusActive": "启用",
|
|
99
|
+
"statusDisabled": "停用",
|
|
100
|
+
"statusOk": "状态已更新",
|
|
101
|
+
"statusOkRevoked": "状态已更新,并吊销了该企业 {{count}} 个登录会话",
|
|
102
|
+
"disable": "停用",
|
|
103
|
+
"enable": "启用",
|
|
104
|
+
"disableConfirm": "停用该应用?企业成员将无法用它登录。",
|
|
105
|
+
"enableConfirm": "启用该应用?",
|
|
106
|
+
"empty": "暂无已开通企业"
|
|
77
107
|
},
|
|
78
108
|
"err": {
|
|
79
109
|
"forbidden": "无权限",
|
|
@@ -91,6 +121,7 @@
|
|
|
91
121
|
"site_title": "站点标题",
|
|
92
122
|
"ldap_enabled": "启用 LDAP 登录",
|
|
93
123
|
"sso_enabled": "启用 SSO 登录",
|
|
124
|
+
"wecom_enabled": "启用企业微信登录",
|
|
94
125
|
"ldap_base_dn": "LDAP Base DN",
|
|
95
126
|
"ldap_uid_attr": "LDAP UID 属性",
|
|
96
127
|
"ldap_email_attr": "LDAP 邮箱属性"
|
|
@@ -8,9 +8,10 @@ const METHOD_KEY: Record<string, string> = {
|
|
|
8
8
|
local: "system:loginMethodsPage.methodLocal",
|
|
9
9
|
ldap: "system:loginMethodsPage.methodLdap",
|
|
10
10
|
sso: "system:loginMethodsPage.methodSso",
|
|
11
|
+
wecom: "system:loginMethodsPage.methodWecom",
|
|
11
12
|
};
|
|
12
13
|
|
|
13
|
-
/** 登录方式发现(C5/D2,只读):local 恒在,ldap/sso 由设置项开关。 */
|
|
14
|
+
/** 登录方式发现(C5/D2,只读):local 恒在,ldap/sso/wecom 由设置项开关。 */
|
|
14
15
|
export default function LoginMethodsPage() {
|
|
15
16
|
const { t } = useTranslation();
|
|
16
17
|
const query = useQuery({ queryKey: ["system", "login-methods"], queryFn: systemLoginMethodsGet });
|
|
@@ -135,7 +135,7 @@ export default function UsersPage() {
|
|
|
135
135
|
onOk={() => createForm.submit()}
|
|
136
136
|
>
|
|
137
137
|
<Form form={createForm} layout="vertical" onFinish={values => createMut.mutate(values)}>
|
|
138
|
-
<Form.Item name="username" label={t("system:users.columns.username")} rules={[{ required: true }]} extra="ldap:/ sso: 为外部身份源保留">
|
|
138
|
+
<Form.Item name="username" label={t("system:users.columns.username")} rules={[{ required: true }]} extra="ldap:/ sso:/ wecom: 为外部身份源保留">
|
|
139
139
|
<Input />
|
|
140
140
|
</Form.Item>
|
|
141
141
|
<Form.Item name="password" label="Password" rules={[{ required: true }, { min: 6 }]} extra={t("system:users.pwdPlaceholder")}>
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { request, useUserStore } from "@oj-module/runtime";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { App, Button, Card, Descriptions, Popconfirm, QRCode, Space, Table, Tag, Typography } from "antd";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
6
|
+
|
|
7
|
+
import { explainError } from "../errors";
|
|
8
|
+
import { ForbiddenPanel } from "../forbidden";
|
|
9
|
+
|
|
10
|
+
/** GET /auth/wecom/admin/apps 的行(后端不返回 permanent_code,secret 不出库) */
|
|
11
|
+
interface AppRow {
|
|
12
|
+
suite_id: string
|
|
13
|
+
corp_id: string
|
|
14
|
+
agent_id: string
|
|
15
|
+
auth_corp_id: string
|
|
16
|
+
env: string
|
|
17
|
+
corp_name: string | null
|
|
18
|
+
status: "active" | "disabled"
|
|
19
|
+
installed_at: number
|
|
20
|
+
update_time: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface InstallInfo {
|
|
24
|
+
installUrl: string
|
|
25
|
+
callbackUrl: string
|
|
26
|
+
expiresIn: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 端点不在本模块的生成 client 里(`__client` 按模块切分),故直接用 runtime 的 ky 出口。
|
|
31
|
+
* 路径不带前导 `/`:与 request 的 baseURL 拼接口径一致(fetchLogin 同款)。
|
|
32
|
+
*/
|
|
33
|
+
async function fetchApps(): Promise<AppRow[]> {
|
|
34
|
+
const env = await request.get("auth/wecom/admin/apps").json<{ code: number, data?: AppRow[] }>();
|
|
35
|
+
return env.data ?? [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function setStatus(input: { corpId: string, agentId: string, status: "active" | "disabled" }): Promise<{ updated: number, revokedSessions: number }> {
|
|
39
|
+
const env = await request.post("auth/wecom/admin/status", { json: input }).json<{ code: number, data?: { updated: number, revokedSessions: number } }>();
|
|
40
|
+
return env.data ?? { updated: 0, revokedSessions: 0 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function createInstall(): Promise<InstallInfo> {
|
|
44
|
+
const env = await request.get("auth/wecom/admin/install").json<{ code: number, data?: InstallInfo }>();
|
|
45
|
+
if (!env.data)
|
|
46
|
+
throw new Error("企业微信未返回开通链接");
|
|
47
|
+
return env.data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 企微代开发后台(服务商运营面,superadmin 独占):
|
|
52
|
+
* · 已授权企业 × 应用清单(同一企业测试/正式两个应用各一行);
|
|
53
|
+
* · 启停(停用企业最后一个应用时后端同时吊销该企业会话);
|
|
54
|
+
* · 开通入口(预授权码 10 分钟有效,每次点都重新换)。
|
|
55
|
+
*/
|
|
56
|
+
export default function WecomAdminPage() {
|
|
57
|
+
const { t } = useTranslation();
|
|
58
|
+
const { message } = App.useApp();
|
|
59
|
+
const queryClient = useQueryClient();
|
|
60
|
+
const roles = useUserStore(s => s.roles);
|
|
61
|
+
const isSuper = roles.includes("superadmin");
|
|
62
|
+
|
|
63
|
+
const [install, setInstall] = useState<InstallInfo | null>(null);
|
|
64
|
+
const [expired, setExpired] = useState(false);
|
|
65
|
+
|
|
66
|
+
// 预授权码只有 10 分钟:到点把码置灰(antd QRCode 的 expired 态自带「刷新」入口),
|
|
67
|
+
// 免得操作员把一张已失效的码发出去、企业管理员点开只看到企微的「链接无效」。
|
|
68
|
+
// 计时器只负责「置灰」,复位放在生成成功那一刻(effect 里同步 setState 会多一次渲染)。
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!install)
|
|
71
|
+
return undefined;
|
|
72
|
+
const timer = setTimeout(setExpired, install.expiresIn * 1000, true);
|
|
73
|
+
return () => clearTimeout(timer);
|
|
74
|
+
}, [install]);
|
|
75
|
+
|
|
76
|
+
const appsQuery = useQuery({
|
|
77
|
+
queryKey: ["system", "wecom-apps"],
|
|
78
|
+
queryFn: fetchApps,
|
|
79
|
+
enabled: isSuper,
|
|
80
|
+
retry: false,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const statusMut = useMutation({
|
|
84
|
+
mutationFn: setStatus,
|
|
85
|
+
onSuccess: (r) => {
|
|
86
|
+
// 吊销了会话就明确说出来:这是「有人被踢下线」的事实,不该只显示「已保存」
|
|
87
|
+
message.success(r.revokedSessions > 0
|
|
88
|
+
? t("system:wecom.statusOkRevoked", { count: r.revokedSessions })
|
|
89
|
+
: t("system:wecom.statusOk"));
|
|
90
|
+
queryClient.invalidateQueries({ queryKey: ["system", "wecom-apps"] });
|
|
91
|
+
},
|
|
92
|
+
onError: e => message.error(explainError(e, t)),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const installMut = useMutation({
|
|
96
|
+
mutationFn: createInstall,
|
|
97
|
+
onSuccess: (r) => {
|
|
98
|
+
setInstall(r);
|
|
99
|
+
setExpired(false);
|
|
100
|
+
// 预授权码一次有效:链接只在该页面的内存里,不写 localStorage
|
|
101
|
+
message.success(t("system:wecom.installOk"));
|
|
102
|
+
},
|
|
103
|
+
onError: e => message.error(explainError(e, t)),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!isSuper)
|
|
107
|
+
return <ForbiddenPanel t={t} />;
|
|
108
|
+
|
|
109
|
+
const columns = [
|
|
110
|
+
{
|
|
111
|
+
title: t("system:wecom.col.corp"),
|
|
112
|
+
dataIndex: "corp_id",
|
|
113
|
+
render: (_: string, row: AppRow) => (
|
|
114
|
+
<div>
|
|
115
|
+
<div>{row.corp_id}</div>
|
|
116
|
+
{row.corp_name && <Typography.Text type="secondary">{row.corp_name}</Typography.Text>}
|
|
117
|
+
</div>
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
{ title: t("system:wecom.col.agent"), dataIndex: "agent_id" },
|
|
121
|
+
{ title: t("system:wecom.col.env"), dataIndex: "env", render: (v: string) => <Tag>{v}</Tag> },
|
|
122
|
+
{
|
|
123
|
+
title: t("system:wecom.col.status"),
|
|
124
|
+
dataIndex: "status",
|
|
125
|
+
render: (v: string) => (
|
|
126
|
+
<Tag color={v === "active" ? "green" : "red"}>
|
|
127
|
+
{t(v === "active" ? "system:wecom.statusActive" : "system:wecom.statusDisabled")}
|
|
128
|
+
</Tag>
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
title: t("system:wecom.col.installedAt"),
|
|
133
|
+
dataIndex: "installed_at",
|
|
134
|
+
render: (v: number) => (v ? new Date(v * 1000).toLocaleString() : "-"),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
title: t("system:wecom.col.action"),
|
|
138
|
+
key: "action",
|
|
139
|
+
render: (_: unknown, row: AppRow) => {
|
|
140
|
+
const next = row.status === "active" ? "disabled" : "active";
|
|
141
|
+
return (
|
|
142
|
+
<Popconfirm
|
|
143
|
+
title={t(next === "disabled" ? "system:wecom.disableConfirm" : "system:wecom.enableConfirm")}
|
|
144
|
+
onConfirm={() => statusMut.mutate({ corpId: row.corp_id, agentId: row.agent_id, status: next })}
|
|
145
|
+
>
|
|
146
|
+
<Button danger={next === "disabled"} size="small" loading={statusMut.isPending}>
|
|
147
|
+
{t(next === "disabled" ? "system:wecom.disable" : "system:wecom.enable")}
|
|
148
|
+
</Button>
|
|
149
|
+
</Popconfirm>
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
|
157
|
+
<Card
|
|
158
|
+
title={t("system:wecom.title")}
|
|
159
|
+
extra={(
|
|
160
|
+
<Button type="primary" loading={installMut.isPending} onClick={() => installMut.mutate()}>
|
|
161
|
+
{t("system:wecom.genInstall")}
|
|
162
|
+
</Button>
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
<Typography.Paragraph type="secondary">{t("system:wecom.hint")}</Typography.Paragraph>
|
|
166
|
+
{install && (
|
|
167
|
+
<div style={{ display: "flex", gap: 24, alignItems: "flex-start", flexWrap: "wrap" }}>
|
|
168
|
+
{/* 二维码用 antd 内置 QRCode(同一份共享 antd,不额外引二维码依赖):
|
|
169
|
+
企业管理员多半在手机上收通知,扫码比复制链接快 */}
|
|
170
|
+
<QRCode
|
|
171
|
+
value={install.installUrl}
|
|
172
|
+
size={160}
|
|
173
|
+
type="svg"
|
|
174
|
+
bordered
|
|
175
|
+
status={expired ? "expired" : "active"}
|
|
176
|
+
onRefresh={() => installMut.mutate()}
|
|
177
|
+
/>
|
|
178
|
+
<Descriptions column={1} size="small" bordered style={{ flex: 1, minWidth: 320 }}>
|
|
179
|
+
<Descriptions.Item label={t("system:wecom.installUrl")}>
|
|
180
|
+
<Typography.Text copyable={{ text: install.installUrl }}>{install.installUrl}</Typography.Text>
|
|
181
|
+
</Descriptions.Item>
|
|
182
|
+
<Descriptions.Item label={t("system:wecom.callbackUrl")}>
|
|
183
|
+
<Typography.Text copyable={{ text: install.callbackUrl }}>{install.callbackUrl}</Typography.Text>
|
|
184
|
+
</Descriptions.Item>
|
|
185
|
+
<Descriptions.Item label={t("system:wecom.expiresIn")}>
|
|
186
|
+
{t("system:wecom.expiresValue", { count: Math.round(install.expiresIn / 60) })}
|
|
187
|
+
</Descriptions.Item>
|
|
188
|
+
</Descriptions>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</Card>
|
|
192
|
+
<Card title={t("system:wecom.appsTitle")}>
|
|
193
|
+
<Table<AppRow>
|
|
194
|
+
rowKey={row => `${row.suite_id}:${row.corp_id}:${row.agent_id}`}
|
|
195
|
+
size="small"
|
|
196
|
+
loading={appsQuery.isLoading}
|
|
197
|
+
dataSource={appsQuery.data ?? []}
|
|
198
|
+
columns={columns}
|
|
199
|
+
pagination={false}
|
|
200
|
+
locale={{ emptyText: t("system:wecom.empty") }}
|
|
201
|
+
/>
|
|
202
|
+
</Card>
|
|
203
|
+
</Space>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
// delete / truncate / all / run / listTables。子集外的调用**直接报「未实现」**,不静默返回空结果
|
|
10
10
|
// (静默空值会把「垫片缺能力」伪装成「业务没数据」,是最难查的一类假绿)。
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// 已知边界(模板当前用不到,不模拟):归属守卫(`owner_of`)——**租户已模拟**,见下。
|
|
13
|
+
// 租户模拟(2026-10-30 补,多租户上线后必须有,否则守卫失效也会绿):
|
|
14
|
+
// · 哪些表按租户隔离不硬编码,建库后按 PRAGMA 找带 `tenant_id` 列的表(与 0015__tenant.sql 同源,
|
|
15
|
+
// 迁移改了这里自动跟随,不会漂移);
|
|
16
|
+
// · 当前租户取 `globalThis.http.tenantId`(与 oj 同源:请求头进来就是它),缺省 `default`;
|
|
17
|
+
// 用例里改 `g.http.tenantId` 即可模拟「伪造 X-TENANT-ID」;
|
|
18
|
+
// · 三条语义与 oj 对齐:insert 自动补 `tenant_id`、select/update/delete/truncate 自动加
|
|
19
|
+
// `tenant_id = ?` 谓词、裸 SQL(db.query/db.exec)触碰租户表却无 `tenant_id` 直接拒
|
|
20
|
+
// (近似 `sql_guard: deny`,是文本级判断,不是解析器)。
|
|
14
21
|
//
|
|
15
22
|
// 分工不变:
|
|
16
23
|
// · L2(本目录 *.spec.ts):构造器渲染出**语义等价**的 SQL 后在真 sqlite 上跑——服务层行为、
|
|
@@ -29,6 +36,45 @@ const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
|
|
|
29
36
|
|
|
30
37
|
export const ADMIN = 1;
|
|
31
38
|
|
|
39
|
+
/** 部署默认租户(与运行时 store/tenant.ts 的 DEFAULT_TENANT 同值) */
|
|
40
|
+
export const DEFAULT_TENANT = "default";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 带 `tenant_id` 列的表 = 租户化表(A 组)。建库时按 PRAGMA 自省后填充,
|
|
44
|
+
* 不在代码里抄一份清单——迁移口径变了这里跟着变。
|
|
45
|
+
*/
|
|
46
|
+
const TENANT_TABLES = new Set<string>();
|
|
47
|
+
|
|
48
|
+
/** 当前请求的租户:与 oj 同源(`http.tenantId`),无请求上下文时落部署默认 */
|
|
49
|
+
function currentTenant(): string {
|
|
50
|
+
const t = (globalThis as any).http?.tenantId;
|
|
51
|
+
return typeof t === "string" && t.trim() ? t.trim() : DEFAULT_TENANT;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isTenantScoped(table: string): boolean {
|
|
55
|
+
return TENANT_TABLES.has(table.toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 裸 SQL 的租户红线段落(近似 `sql_guard: deny`):SQL 里出现租户表却整条没有 `tenant_id`
|
|
60
|
+
* 就拒。文本级判断,只挡「整条忘了带租户」这一类,挡不住精心构造的绕过——真判据在 oj 侧。
|
|
61
|
+
*/
|
|
62
|
+
const TENANT_SQL_TABLE_RE = /\b(?:from|into|update|join)\s+"?([a-z_]\w*)"?/gi;
|
|
63
|
+
|
|
64
|
+
function guardTenantSql(sql: string): void {
|
|
65
|
+
if (TENANT_TABLES.size === 0)
|
|
66
|
+
return;
|
|
67
|
+
const touched = [...sql.matchAll(TENANT_SQL_TABLE_RE)]
|
|
68
|
+
.map(m => m[1]!.toLowerCase())
|
|
69
|
+
.filter(t => isTenantScoped(t));
|
|
70
|
+
if (touched.length === 0 || /\btenant_id\b/i.test(sql))
|
|
71
|
+
return;
|
|
72
|
+
throw new Error(
|
|
73
|
+
`[fixture] 裸 SQL 触碰租户表 ${[...new Set(touched)].join(", ")} 但未写 tenant_id:`
|
|
74
|
+
+ "tenant.enable + sql_guard: deny 下真 oj 会拒(租户值须走参数,不拼字符串)",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
32
78
|
/** 标识符加引号(保留字列名如 `order` / `group` / `count` 全按字面量处理;`表.列` 逐段加) */
|
|
33
79
|
function quoteIdent(name: string): string {
|
|
34
80
|
return name.split(".").map(p => `"${p.replace(/"/g, "\"\"")}"`).join(".");
|
|
@@ -268,15 +314,25 @@ class FakeQuery {
|
|
|
268
314
|
return this.compileUpdate();
|
|
269
315
|
if (s.verb === "delete")
|
|
270
316
|
return this.compileDelete();
|
|
271
|
-
if (s.verb === "truncate")
|
|
272
|
-
|
|
317
|
+
if (s.verb === "truncate") {
|
|
318
|
+
const tenant = this.tenantPredicate();
|
|
319
|
+
return {
|
|
320
|
+
sql: `DELETE FROM ${quoteIdent(s.table)}${tenant ? ` WHERE ${tenant.sql}` : ""}`,
|
|
321
|
+
params: tenant ? tenant.params : [],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
273
324
|
return this.compileSelect(inner);
|
|
274
325
|
}
|
|
275
326
|
|
|
276
327
|
private compileInsert(): Compiled {
|
|
277
|
-
const
|
|
278
|
-
if (
|
|
328
|
+
const raw = (Array.isArray(this.s.values) ? this.s.values : [this.s.values]) as Array<Record<string, unknown>>;
|
|
329
|
+
if (raw.length === 0)
|
|
279
330
|
throw new Error("[fixture] insert 至少需要一行");
|
|
331
|
+
// 租户化表:调用方不写 tenant_id 时自动补当前租户(oj 构造器同语义——
|
|
332
|
+
// 业务代码不该自己写 tenant_id,注入失效才是最危险的失败模式)
|
|
333
|
+
const rows = isTenantScoped(this.s.table)
|
|
334
|
+
? raw.map(r => ("tenant_id" in r ? r : { ...r, tenant_id: currentTenant() }))
|
|
335
|
+
: raw;
|
|
280
336
|
const cols = Object.keys(rows[0]!);
|
|
281
337
|
for (const r of rows) {
|
|
282
338
|
if (Object.keys(r).length !== cols.length)
|
|
@@ -309,11 +365,21 @@ class FakeQuery {
|
|
|
309
365
|
return { sql: `DELETE FROM ${quoteIdent(this.s.table)}${where.sql}`, params: where.params };
|
|
310
366
|
}
|
|
311
367
|
|
|
312
|
-
/**
|
|
368
|
+
/** 基表租户谓词(表名/别名限定,与 oj 渲染的 `"users"."tenant_id"` 同形);共享表返回 null */
|
|
369
|
+
private tenantPredicate(): Compiled | null {
|
|
370
|
+
if (!isTenantScoped(this.s.table))
|
|
371
|
+
return null;
|
|
372
|
+
return { sql: `${quoteIdent(this.s.alias ?? this.s.table)}.tenant_id = ?`, params: [currentTenant()] };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** where 子句(追加进既有 params,返回拼接后的片段);租户谓词始终参与 AND */
|
|
313
376
|
private compileWhereClause(params: unknown[]): Compiled {
|
|
314
|
-
if (this.s.wheres.length === 0)
|
|
315
|
-
return { sql: "", params };
|
|
316
377
|
const parts = this.s.wheres.map(compileWhere);
|
|
378
|
+
const tenant = this.tenantPredicate();
|
|
379
|
+
if (tenant)
|
|
380
|
+
parts.push(tenant);
|
|
381
|
+
if (parts.length === 0)
|
|
382
|
+
return { sql: "", params };
|
|
317
383
|
return {
|
|
318
384
|
sql: ` WHERE ${parts.map(p => `(${p.sql})`).join(" AND ")}`,
|
|
319
385
|
params: [...params, ...parts.flatMap(p => p.params)],
|
|
@@ -408,10 +474,24 @@ export function installDb(): any {
|
|
|
408
474
|
dbo.exec(readFileSync(join(dir, f), "utf8"));
|
|
409
475
|
}
|
|
410
476
|
|
|
477
|
+
// 租户化表自省:有 `tenant_id` 列即按租户隔离(迁移是唯一真相,不在夹具里抄清单)
|
|
478
|
+
TENANT_TABLES.clear();
|
|
479
|
+
for (const { name } of dbo.prepare(SQL_LIST_TABLES).all() as Array<{ name: string }>) {
|
|
480
|
+
const cols = dbo.prepare(`PRAGMA table_info(${quoteIdent(name)})`).all() as Array<{ name: string }>;
|
|
481
|
+
if (cols.some(c => c.name.toLowerCase() === "tenant_id"))
|
|
482
|
+
TENANT_TABLES.add(name.toLowerCase());
|
|
483
|
+
}
|
|
484
|
+
|
|
411
485
|
dbo.prepare("INSERT INTO users (id, username, password_hash) VALUES (?, ?, 'x')").run(ADMIN, "admin");
|
|
412
486
|
|
|
413
|
-
const query = async (sql: string, params: unknown[] = []) =>
|
|
414
|
-
|
|
487
|
+
const query = async (sql: string, params: unknown[] = []) => {
|
|
488
|
+
guardTenantSql(sql);
|
|
489
|
+
return dbo.prepare(sql).all(...(params as never[]));
|
|
490
|
+
};
|
|
491
|
+
const exec = async (sql: string, params: unknown[] = []) => {
|
|
492
|
+
guardTenantSql(sql);
|
|
493
|
+
return Number(dbo.prepare(sql).run(...(params as never[])).changes);
|
|
494
|
+
};
|
|
415
495
|
const table = (name: string) => new FakeQuery(dbo, name);
|
|
416
496
|
const txInstance = { query, exec, table, listTables: (prefix?: string) => listTables(dbo, prefix), isTx: true };
|
|
417
497
|
// db.tx 镜像运行时面:BEGIN/COMMIT/ROLLBACK 包裹回调(versions.ts 注释同语义)
|
|
@@ -43,17 +43,23 @@ describe("spike 夹具冒烟:真实迁移 + 运行时全局面 + 事务", () =
|
|
|
43
43
|
expect(row.username).toBe("admin");
|
|
44
44
|
});
|
|
45
45
|
it("全局 db.query/exec 与运行时同面可用", async () => {
|
|
46
|
-
const rows = await db.query("SELECT id, username FROM users");
|
|
46
|
+
const rows = await db.query("SELECT id, username FROM users WHERE tenant_id = ?", ["default"]);
|
|
47
47
|
expect(rows.length).toBeGreaterThan(0);
|
|
48
48
|
});
|
|
49
|
+
it("裸 SQL 漏写 tenant_id 一律拒(sql_guard: deny 的夹具替身)", async () => {
|
|
50
|
+
await expect(db.query("SELECT id FROM users")).rejects.toThrow(/未写 tenant_id/);
|
|
51
|
+
await expect(db.exec("DELETE FROM users")).rejects.toThrow(/未写 tenant_id/);
|
|
52
|
+
// B 组共享表无 tenant_id,照旧放行
|
|
53
|
+
await expect(db.query("SELECT key FROM sys_setting")).resolves.toBeDefined();
|
|
54
|
+
});
|
|
49
55
|
it("db.tx 事务往返:提交持久化 / 回滚不留痕", async () => {
|
|
50
56
|
await db.tx(async (t) => {
|
|
51
|
-
await t.exec("INSERT INTO users (username, password_hash) VALUES ('tx-ok', 'x')");
|
|
57
|
+
await t.exec("INSERT INTO users (tenant_id, username, password_hash) VALUES ('default', 'tx-ok', 'x')");
|
|
52
58
|
});
|
|
53
59
|
const ok = dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE username = 'tx-ok'").get() as { n: number };
|
|
54
60
|
expect(Number(ok.n)).toBe(1);
|
|
55
61
|
await expect(db.tx(async (t) => {
|
|
56
|
-
await t.exec("INSERT INTO users (username, password_hash) VALUES ('tx-bad', 'x')");
|
|
62
|
+
await t.exec("INSERT INTO users (tenant_id, username, password_hash) VALUES ('default', 'tx-bad', 'x')");
|
|
57
63
|
throw new Error("rollback");
|
|
58
64
|
})).rejects.toThrow("rollback");
|
|
59
65
|
const bad = dbo.prepare("SELECT COUNT(*) AS n FROM users WHERE username = 'tx-bad'").get() as { n: number };
|
|
@@ -326,27 +326,29 @@ describe("smoke 探活(D14/Q-A:health 不强制)", () => {
|
|
|
326
326
|
expect(r.ok).toBe(true);
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
-
it("health 401
|
|
329
|
+
it("health 401(无凭证探针,restart_pending 常态)→ 一律判通过 + 落待重启提示", async () => {
|
|
330
330
|
await seedDeployed();
|
|
331
331
|
const r = await smoke(smCtx({
|
|
332
332
|
manifest: solManifest({ health: "/demo-solution/ping" }),
|
|
333
333
|
fetch: async () => ({ ok: false, status: 401 }),
|
|
334
|
-
deferredAnonymous: ["/demo-solution/ping"],
|
|
335
334
|
}));
|
|
336
335
|
expect(r.ok).toBe(true);
|
|
337
336
|
expect(r.message).toContain("restart_pending");
|
|
338
|
-
expect(r.message).toContain("
|
|
337
|
+
expect(r.message).toContain("不计入失败");
|
|
339
338
|
});
|
|
340
339
|
|
|
341
|
-
it("health 401
|
|
340
|
+
it("health 401 与否声明 anonymous 无关(统一 deferred,不放宽真失败靠非 401)", async () => {
|
|
341
|
+
// 旧语义按 deferredAnonymous 白名单区分是否 deferred;该字段从未被调用方赋值(死代码),
|
|
342
|
+
// 且设计明确「health 不强制、无凭证预期 401」,故改为 health 401 一律 deferred。
|
|
343
|
+
// 真失败仍由非 401(如 500,见下条)兜底,不靠 anonymous 白名单。
|
|
342
344
|
await seedDeployed();
|
|
343
345
|
const r = await smoke(smCtx({
|
|
344
346
|
manifest: solManifest({ health: "/demo-solution/ping" }),
|
|
345
347
|
fetch: async () => ({ ok: false, status: 401 }),
|
|
346
348
|
deferredAnonymous: ["/demo-solution/other"],
|
|
347
349
|
}));
|
|
348
|
-
expect(r.ok).toBe(
|
|
349
|
-
expect(r.message).toContain("
|
|
350
|
+
expect(r.ok).toBe(true);
|
|
351
|
+
expect(r.message).toContain("restart_pending");
|
|
350
352
|
});
|
|
351
353
|
|
|
352
354
|
it("health 非 2xx → 探活失败", async () => {
|
|
@@ -1237,3 +1237,79 @@ describe("P1-16 接线:reconcile 不一致自动修复(重跑 setEnabled)+
|
|
|
1237
1237
|
expect(mig?.opts).toEqual({ timeoutMs: 600000 });
|
|
1238
1238
|
});
|
|
1239
1239
|
});
|
|
1240
|
+
|
|
1241
|
+
describe("冒烟探活必须自报租户头(企微代开发多租户 v3 Phase 0)", () => {
|
|
1242
|
+
it("probe fetch 带 X-TENANT-ID: default —— 宿主 tenant.enable 时匿名健康端点也强制要租户头,缺即 400", async () => {
|
|
1243
|
+
const seen: Array<{ url: string, headers?: Record<string, string> }> = [];
|
|
1244
|
+
const t = await runInstall(makeReq({
|
|
1245
|
+
fetch: async (url: string, init?: { headers?: Record<string, string> }) => {
|
|
1246
|
+
seen.push({ url, headers: init?.headers });
|
|
1247
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer };
|
|
1248
|
+
},
|
|
1249
|
+
}));
|
|
1250
|
+
expect(t.status).toBe("done");
|
|
1251
|
+
// 探活命中的是首个非匿名路由 /demo-solution(anonymous 仅 demo-solution/ping)
|
|
1252
|
+
const probe = seen.find(c => c.url === "/api/demo-solution/demo-solution");
|
|
1253
|
+
expect(probe, "smoke 应对首个非匿名路由发起探活").toBeDefined();
|
|
1254
|
+
expect(probe!.headers?.["X-TENANT-ID"]).toBe("default");
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
it("遇「缺租户头即 400」的宿主时因已自报 default 而通过(属性测试,非假绿)", async () => {
|
|
1258
|
+
// 模拟宿主 tenant.enable 的硬 fail-closed:探针缺 X-TENANT-ID 即 400、带 default 即 200。
|
|
1259
|
+
// 本例用 health 端点(smoke 对 health 断言 2xx)——若 smoke 没带租户头,探针必 400 → smoke 失败。
|
|
1260
|
+
// 安装成功(done) 即反向证明租户头确已注入,而非靠「非 health 探活忽略 2xx」蒙混。
|
|
1261
|
+
const seen: Array<{ url: string, headers?: Record<string, string> }> = [];
|
|
1262
|
+
const t = await runInstall(makeReq({
|
|
1263
|
+
manifest: { ...manifestFixture("0.1.0"), health: "/demo-solution/ping" },
|
|
1264
|
+
fetch: async (url: string, init?: { headers?: Record<string, string> }) => {
|
|
1265
|
+
seen.push({ url, headers: init?.headers });
|
|
1266
|
+
// 仅对探活 URL 模拟「缺租户头即 400」;下载 CDN 等一律 200,避免误伤其他阶段。
|
|
1267
|
+
if (url === "/api/demo-solution/demo-solution/ping" && init?.headers?.["X-TENANT-ID"] !== "default") {
|
|
1268
|
+
return { ok: false, status: 400, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1269
|
+
}
|
|
1270
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer };
|
|
1271
|
+
},
|
|
1272
|
+
}));
|
|
1273
|
+
expect(t.status).toBe("done");
|
|
1274
|
+
const probe = seen.find(c => c.url === "/api/demo-solution/demo-solution/ping");
|
|
1275
|
+
expect(probe, "health 优先被探活").toBeDefined();
|
|
1276
|
+
expect(probe!.headers?.["X-TENANT-ID"]).toBe("default");
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it("health 端点声明为匿名:探针 401(restart_pending)应判 deferred 而非失败", async () => {
|
|
1280
|
+
// 租户化站点上「本次新增的匿名端点要重启才生效」,探活收 401 是预期,不计入失败(设计 §4.1)。
|
|
1281
|
+
// 复现 reinstall/upgrade 在隔离站点的 401 误判失败:修复前 deferredAnonymous 从未被赋值 → 误失败。
|
|
1282
|
+
const t = await runInstall(makeReq({
|
|
1283
|
+
manifest: {
|
|
1284
|
+
...manifestFixture("0.1.0"),
|
|
1285
|
+
health: "/demo-solution/ping",
|
|
1286
|
+
api: { prefix: "/api/demo-solution", anonymous: ["demo-solution/ping"] },
|
|
1287
|
+
},
|
|
1288
|
+
fetch: async (url: string) => {
|
|
1289
|
+
if (url === "/api/demo-solution/demo-solution/ping") {
|
|
1290
|
+
return { ok: false, status: 401, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1291
|
+
}
|
|
1292
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer };
|
|
1293
|
+
},
|
|
1294
|
+
}));
|
|
1295
|
+
expect(t.status).toBe("done");
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
it("health 端点收非 401(如 500):仍判失败(仅 401 转 deferred)", async () => {
|
|
1299
|
+
const t = await runInstall(makeReq({
|
|
1300
|
+
manifest: {
|
|
1301
|
+
...manifestFixture("0.1.0"),
|
|
1302
|
+
health: "/demo-solution/ping",
|
|
1303
|
+
api: { prefix: "/api/demo-solution", anonymous: ["demo-solution/ping"] },
|
|
1304
|
+
},
|
|
1305
|
+
fetch: async (url: string) => {
|
|
1306
|
+
if (url === "/api/demo-solution/demo-solution/ping") {
|
|
1307
|
+
return { ok: false, status: 500, arrayBuffer: async () => new ArrayBuffer(0) };
|
|
1308
|
+
}
|
|
1309
|
+
return { ok: true, status: 200, arrayBuffer: async () => ARCHIVE.slice().buffer };
|
|
1310
|
+
},
|
|
1311
|
+
}));
|
|
1312
|
+
expect(t.status).toBe("partial");
|
|
1313
|
+
expect(t.stage).toBe("smoke");
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
@@ -280,7 +280,7 @@ describe("信任库运维端点(superadmin,R9)", () => {
|
|
|
280
280
|
await api.trust.key.del();
|
|
281
281
|
expect(last).toMatchObject({ status: 400 });
|
|
282
282
|
|
|
283
|
-
const rows = await (g.db.query as (sql: string, p?: unknown[]) => Promise<Array<Record<string, unknown>>>)("select action, target, detail from sys_oplog where action like 'solution.trust.%' order by id");
|
|
283
|
+
const rows = await (g.db.query as (sql: string, p?: unknown[]) => Promise<Array<Record<string, unknown>>>)("select action, target, detail from sys_oplog where tenant_id = ? and action like 'solution.trust.%' order by id", ["default"]);
|
|
284
284
|
expect(rows.map(r => r.action)).toEqual(["solution.trust.add", "solution.trust.revoke"]);
|
|
285
285
|
expect(rows[0]).toMatchObject({ target: "ojm.test" });
|
|
286
286
|
expect(JSON.parse(String(rows[0].detail))).toMatchObject({ key_id: "ojm.test", operator: "admin" });
|
|
@@ -14,12 +14,15 @@ beforeAll(() => {
|
|
|
14
14
|
dbo.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(`u${String(i).padStart(3, "0")}`);
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// 裸 SQL(paged 直通):租户化表必须显式写 tenant_id 且走参数——tenant.enable + sql_guard: deny
|
|
18
|
+
// 下缺它会被真 oj 拒(夹具的 guardTenantSql 同样拒,见 tests/fixture.ts 头注释)
|
|
19
|
+
const TENANT = "default";
|
|
20
|
+
const LIST = "select id, username from users where tenant_id = ?";
|
|
21
|
+
const COUNT = "select count(*) as c from users where tenant_id = ?";
|
|
19
22
|
|
|
20
23
|
describe("AC1-24:limit 钳制与截断标记", () => {
|
|
21
24
|
it("size 静默钳 200,truncated=true(库 301 人=夹具 admin+300)", async () => {
|
|
22
|
-
const r = await paged<any>(LIST, COUNT, [], { page: 1, size: 500 });
|
|
25
|
+
const r = await paged<any>(LIST, COUNT, [TENANT], { page: 1, size: 500 });
|
|
23
26
|
expect(r.size).toBe(200);
|
|
24
27
|
expect(r.list.length).toBe(200);
|
|
25
28
|
expect(r.total).toBe(301);
|
|
@@ -27,16 +30,16 @@ describe("AC1-24:limit 钳制与截断标记", () => {
|
|
|
27
30
|
});
|
|
28
31
|
|
|
29
32
|
it("末页 truncated=false", async () => {
|
|
30
|
-
const r = await paged<any>(LIST, COUNT, [], { page: 2, size: 200 });
|
|
33
|
+
const r = await paged<any>(LIST, COUNT, [TENANT], { page: 2, size: 200 });
|
|
31
34
|
expect(r.list.length).toBe(101);
|
|
32
35
|
expect(r.truncated).toBe(false);
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
it("缺省 page=1 / size=20;页码/尺寸非法值回落(0/负数/非数)", async () => {
|
|
36
|
-
const d = await paged<any>(LIST, COUNT, [], {});
|
|
39
|
+
const d = await paged<any>(LIST, COUNT, [TENANT], {});
|
|
37
40
|
expect(d.page).toBe(1);
|
|
38
41
|
expect(d.size).toBe(20);
|
|
39
|
-
const bad = await paged<any>(LIST, COUNT, [], { page: 0, size: -3 });
|
|
42
|
+
const bad = await paged<any>(LIST, COUNT, [TENANT], { page: 0, size: -3 });
|
|
40
43
|
expect(bad.page).toBe(1);
|
|
41
44
|
expect(bad.size).toBe(20);
|
|
42
45
|
});
|
|
@@ -54,9 +57,9 @@ describe("AC1-22:LIKE 模式与 keyword 长度红线", () => {
|
|
|
54
57
|
it("LIKE 元字符转义:% _ ! 原样匹配不泛化", async () => {
|
|
55
58
|
dbo.prepare("INSERT INTO users (username, password_hash) VALUES ('a%b_c!d', 'x')").run();
|
|
56
59
|
const r = await paged<any>(
|
|
57
|
-
"select id, username from users where username like ? escape '!'",
|
|
58
|
-
"select count(*) as c from users where username like ? escape '!'",
|
|
59
|
-
[likePattern("a%b_c!d")],
|
|
60
|
+
"select id, username from users where tenant_id = ? and username like ? escape '!'",
|
|
61
|
+
"select count(*) as c from users where tenant_id = ? and username like ? escape '!'",
|
|
62
|
+
[TENANT, likePattern("a%b_c!d")],
|
|
60
63
|
{ size: 10 },
|
|
61
64
|
);
|
|
62
65
|
expect(r.list.map((x: any) => x.username)).toEqual(["a%b_c!d"]);
|