@koishi-ce/plugin-auth 1.0.0 → 1.0.2
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/README.md +106 -0
- package/dist/index.js +1 -1
- package/dist/{index.css → style.css} +1 -1
- package/lib/assets/{zh-CN-KbNPwFxh.yml → zh-CN-DChWGTwa.yml} +4 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.mjs +1 -1
- package/locales/de-DE.yml +4 -0
- package/locales/en-US.yml +4 -0
- package/locales/fr-FR.yml +4 -0
- package/locales/ja-JP.yml +4 -0
- package/locales/ru-RU.yml +4 -0
- package/locales/zh-CN.yml +4 -0
- package/locales/zh-TW.yml +4 -0
- package/package.json +6 -4
- package/src/__tests__/index.test.ts +797 -0
- package/src/index.ts +275 -141
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2019-present Shigma and Koishijs contributors.
|
|
3
|
+
// Copyright (c) 2026-present Koishi-CE contributors.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @koishi-ce/plugin-auth(控制台鉴权)的行为测试。
|
|
7
|
+
*
|
|
8
|
+
* 以内存数据库 + TestConsole(内存 WebSocket 客户端)驱动真实 RPC 链路,
|
|
9
|
+
* 覆盖:管理员账户初始化、密码登录(含旧版 SHA-256 哈希透明升级)、
|
|
10
|
+
* 令牌续期登录、平台验证码两步登录与绑定改挂、权限拦截、
|
|
11
|
+
* 令牌删除 / 登出 / 资料更新 / 解绑等用户管理事件。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
afterAll,
|
|
16
|
+
beforeAll,
|
|
17
|
+
describe,
|
|
18
|
+
expect,
|
|
19
|
+
it,
|
|
20
|
+
} from "bun:test";
|
|
21
|
+
import {
|
|
22
|
+
createHash,
|
|
23
|
+
pbkdf2Sync,
|
|
24
|
+
randomBytes,
|
|
25
|
+
} from "node:crypto";
|
|
26
|
+
import type { IncomingMessage } from "node:http";
|
|
27
|
+
import {
|
|
28
|
+
type Client,
|
|
29
|
+
Console,
|
|
30
|
+
type Entry,
|
|
31
|
+
} from "@koishi-ce/console";
|
|
32
|
+
import {
|
|
33
|
+
App,
|
|
34
|
+
Logger,
|
|
35
|
+
type Plugin,
|
|
36
|
+
Time,
|
|
37
|
+
type Universal,
|
|
38
|
+
} from "@koishi-ce/koishi";
|
|
39
|
+
import auth, {
|
|
40
|
+
type Auth,
|
|
41
|
+
randomId,
|
|
42
|
+
} from "@koishi-ce/plugin-auth";
|
|
43
|
+
import mockClient from "@koishi-ce/plugin-mock";
|
|
44
|
+
import memory from "@koishijs/plugin-database-memory";
|
|
45
|
+
|
|
46
|
+
// 声明测试专用事件(带权限门槛),驱动 console/intercept 鉴权链路
|
|
47
|
+
declare module "@koishi-ce/console" {
|
|
48
|
+
interface Events {
|
|
49
|
+
"test/admin-only"(): string;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 出站消息形状 */
|
|
54
|
+
interface SentMessage {
|
|
55
|
+
type: string;
|
|
56
|
+
body: {
|
|
57
|
+
id?: number;
|
|
58
|
+
key?: string;
|
|
59
|
+
value?: unknown;
|
|
60
|
+
error?: string;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 内存 WebSocket 桩 */
|
|
65
|
+
class FakeSocket {
|
|
66
|
+
sent: string[] = [];
|
|
67
|
+
// message 与 close 的监听器统一为同构签名(never 载荷),保证集合存取类型一致
|
|
68
|
+
private messageHandlers = new Set<
|
|
69
|
+
(event: never) => void
|
|
70
|
+
>();
|
|
71
|
+
private closeHandlers = new Set<(event: never) => void>();
|
|
72
|
+
|
|
73
|
+
send(data: string) {
|
|
74
|
+
this.sent.push(data);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
addEventListener(
|
|
78
|
+
type: string,
|
|
79
|
+
listener: (event: never) => void,
|
|
80
|
+
) {
|
|
81
|
+
if (type === "message")
|
|
82
|
+
this.messageHandlers.add(listener);
|
|
83
|
+
if (type === "close") this.closeHandlers.add(listener);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
removeEventListener(
|
|
87
|
+
type: string,
|
|
88
|
+
listener: (event: never) => void,
|
|
89
|
+
) {
|
|
90
|
+
if (type === "message")
|
|
91
|
+
this.messageHandlers.delete(listener);
|
|
92
|
+
if (type === "close")
|
|
93
|
+
this.closeHandlers.delete(listener);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
receive(text: string) {
|
|
97
|
+
for (const handler of this.messageHandlers) {
|
|
98
|
+
handler({ data: Buffer.from(text) } as never);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
shutdown() {
|
|
103
|
+
// close 监听器签名统一带 never 载荷,调用时补占位实参
|
|
104
|
+
for (const handler of this.closeHandlers)
|
|
105
|
+
handler(undefined as never);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get socket(): Universal.WebSocket {
|
|
109
|
+
return this as unknown as Universal.WebSocket;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fakeRequest(headers: Record<string, string> = {}) {
|
|
114
|
+
return {
|
|
115
|
+
headers,
|
|
116
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
117
|
+
} as unknown as IncomingMessage;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function tick(ms = 30) {
|
|
121
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Console 抽象基类的最小实现 */
|
|
125
|
+
class TestConsole extends Console {
|
|
126
|
+
resolveEntry(files: Entry.Files, key: string): string[] {
|
|
127
|
+
const list =
|
|
128
|
+
typeof files === "string" || Array.isArray(files)
|
|
129
|
+
? files
|
|
130
|
+
: files.prod;
|
|
131
|
+
return [String(list), key];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
acceptClient(
|
|
135
|
+
socket: Universal.WebSocket,
|
|
136
|
+
request: IncomingMessage,
|
|
137
|
+
): Client {
|
|
138
|
+
let accepted: Client | undefined;
|
|
139
|
+
const dispose = this.ctx.on(
|
|
140
|
+
"console/connection",
|
|
141
|
+
(client) => {
|
|
142
|
+
accepted = client;
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
this.accept(socket, request);
|
|
146
|
+
dispose();
|
|
147
|
+
if (!accepted) throw new Error("client not accepted");
|
|
148
|
+
return accepted;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 测试客户端:包一层真实 Client,提供 RPC 调用与消息读取 */
|
|
153
|
+
class TestClient {
|
|
154
|
+
readonly socket = new FakeSocket();
|
|
155
|
+
readonly client: Client;
|
|
156
|
+
private nextId = 0;
|
|
157
|
+
|
|
158
|
+
constructor(
|
|
159
|
+
service: TestConsole,
|
|
160
|
+
headers: Record<string, string> = {},
|
|
161
|
+
) {
|
|
162
|
+
this.client = service.acceptClient(
|
|
163
|
+
this.socket.socket,
|
|
164
|
+
fakeRequest(headers),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 发起一次 RPC 并轮询等待回执(PBKDF2 校验耗时较长,固定等待不可靠) */
|
|
169
|
+
async call(
|
|
170
|
+
type: string,
|
|
171
|
+
args: unknown[],
|
|
172
|
+
timeout = 5000,
|
|
173
|
+
) {
|
|
174
|
+
const id = ++this.nextId;
|
|
175
|
+
this.socket.receive(JSON.stringify({ type, args, id }));
|
|
176
|
+
const deadline = Date.now() + timeout;
|
|
177
|
+
for (;;) {
|
|
178
|
+
const response = this.messages().find(
|
|
179
|
+
(msg) =>
|
|
180
|
+
msg.type === "response" && msg.body.id === id,
|
|
181
|
+
);
|
|
182
|
+
if (response) return response.body;
|
|
183
|
+
if (Date.now() > deadline) {
|
|
184
|
+
throw new Error(`no response for ${type}`);
|
|
185
|
+
}
|
|
186
|
+
await tick(10);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 最近一条 user 数据消息 */
|
|
191
|
+
lastUserData() {
|
|
192
|
+
const data = this.messages()
|
|
193
|
+
.filter(
|
|
194
|
+
(msg) =>
|
|
195
|
+
msg.type === "data" && msg.body.key === "user",
|
|
196
|
+
)
|
|
197
|
+
.at(-1);
|
|
198
|
+
return data?.body.value as
|
|
199
|
+
| (Auth & { tokens: unknown[] })
|
|
200
|
+
| null
|
|
201
|
+
| undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
messages(): SentMessage[] {
|
|
205
|
+
return this.socket.sent.map(
|
|
206
|
+
(line) => JSON.parse(line) as SentMessage,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
close() {
|
|
211
|
+
this.socket.shutdown();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const app = new App();
|
|
216
|
+
// 同 admin:CJS 实现配 ESM 声明,nodenext 互操作视图多包一层 default,类型层穿透取真实类
|
|
217
|
+
app.plugin(memory as unknown as typeof memory.default);
|
|
218
|
+
app.plugin(mockClient);
|
|
219
|
+
// Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
|
|
220
|
+
app.plugin(
|
|
221
|
+
TestConsole as unknown as Plugin.Constructor<App>,
|
|
222
|
+
);
|
|
223
|
+
app.plugin(auth, {
|
|
224
|
+
admin: {
|
|
225
|
+
enabled: true,
|
|
226
|
+
username: "root",
|
|
227
|
+
password: "admin-pass",
|
|
228
|
+
},
|
|
229
|
+
authTokenExpire: Time.week,
|
|
230
|
+
loginTokenExpire: Time.minute * 5,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const service = () => app.console as TestConsole;
|
|
234
|
+
let aliceId = 0;
|
|
235
|
+
let bobId = 0;
|
|
236
|
+
|
|
237
|
+
beforeAll(async () => {
|
|
238
|
+
// 「creating admin account」是启动期一次性生命周期 info,收敛为仅错误级
|
|
239
|
+
(Logger.levels as Record<string, number>)["auth"] = 1;
|
|
240
|
+
await app.start();
|
|
241
|
+
// alice:用于平台验证码登录;bob:用于绑定改挂场景
|
|
242
|
+
const alice = await app.database.createUser(
|
|
243
|
+
"mock",
|
|
244
|
+
"111",
|
|
245
|
+
{
|
|
246
|
+
name: "alice",
|
|
247
|
+
authority: 2,
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
const bob = await app.database.createUser("mock", "222", {
|
|
251
|
+
name: "bob",
|
|
252
|
+
authority: 1,
|
|
253
|
+
});
|
|
254
|
+
aliceId = alice.id;
|
|
255
|
+
bobId = bob.id;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
afterAll(async () => {
|
|
259
|
+
// 先注销全部入口并等异步刷新落地,避免停机期间异步回访已卸载的 console 服务
|
|
260
|
+
for (const entry of Object.values(service().entries)) {
|
|
261
|
+
entry.dispose();
|
|
262
|
+
}
|
|
263
|
+
await tick();
|
|
264
|
+
await app.stop();
|
|
265
|
+
delete (Logger.levels as Record<string, number>)["auth"];
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/** 以管理员身份登录并返回客户端 */
|
|
269
|
+
async function loginAdmin() {
|
|
270
|
+
const client = new TestClient(service(), {
|
|
271
|
+
"user-agent": "test-agent",
|
|
272
|
+
"x-forwarded-for": "1.2.3.4",
|
|
273
|
+
});
|
|
274
|
+
await client.call("login/password", [
|
|
275
|
+
"root",
|
|
276
|
+
"admin-pass",
|
|
277
|
+
]);
|
|
278
|
+
return client;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
describe("@koishi-ce/plugin-auth", () => {
|
|
282
|
+
it("启动时创建管理员账户;randomId 生成随机令牌", async () => {
|
|
283
|
+
const [admin] = await app.database.get("user", {
|
|
284
|
+
id: 0,
|
|
285
|
+
});
|
|
286
|
+
expect(admin?.name).toBe("root");
|
|
287
|
+
expect(admin?.authority).toBe(5);
|
|
288
|
+
expect(admin?.password?.startsWith("pbkdf2$")).toBe(
|
|
289
|
+
true,
|
|
290
|
+
);
|
|
291
|
+
expect(randomId(8)).toMatch(/^[0-9a-zA-Z]{8}$/);
|
|
292
|
+
expect(randomId()).toHaveLength(40);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("密码登录", () => {
|
|
296
|
+
it("密码错误与账户缺失时拒绝", async () => {
|
|
297
|
+
const client = new TestClient(service());
|
|
298
|
+
const wrong = await client.call("login/password", [
|
|
299
|
+
"root",
|
|
300
|
+
"wrong-pass",
|
|
301
|
+
]);
|
|
302
|
+
expect(wrong.error).toContain("用户名或密码错误");
|
|
303
|
+
const missing = await client.call("login/password", [
|
|
304
|
+
"nobody",
|
|
305
|
+
"x",
|
|
306
|
+
]);
|
|
307
|
+
expect(missing.error).toContain("用户名或密码错误");
|
|
308
|
+
client.close();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("登录成功签发令牌并下发登录态", async () => {
|
|
312
|
+
const client = new TestClient(service(), {
|
|
313
|
+
"user-agent": "test-agent",
|
|
314
|
+
"x-forwarded-for": "1.2.3.4",
|
|
315
|
+
});
|
|
316
|
+
const response = await client.call("login/password", [
|
|
317
|
+
"root",
|
|
318
|
+
"admin-pass",
|
|
319
|
+
]);
|
|
320
|
+
expect(response.error).toBeUndefined();
|
|
321
|
+
expect(client.client.auth?.id).toBe(0);
|
|
322
|
+
expect(client.client.auth?.token).toHaveLength(40);
|
|
323
|
+
|
|
324
|
+
// 令牌落库记录来源信息(auth 已由上方断言保证存在)
|
|
325
|
+
const [row] = await app.database.get("token", {
|
|
326
|
+
token: client.client.auth!.token,
|
|
327
|
+
});
|
|
328
|
+
expect(row?.userAgent).toBe("test-agent");
|
|
329
|
+
expect(row?.address).toBe("1.2.3.4");
|
|
330
|
+
expect(row?.type).toBe("password");
|
|
331
|
+
expect(row?.expiredAt).toBeGreaterThan(Date.now());
|
|
332
|
+
|
|
333
|
+
// 下发的 user 数据附带会话与绑定明细
|
|
334
|
+
const data = client.lastUserData();
|
|
335
|
+
expect(data?.name).toBe("root");
|
|
336
|
+
expect(Array.isArray(data?.tokens)).toBe(true);
|
|
337
|
+
client.close();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("旧版无盐 SHA-256 哈希校验通过后透明升级", async () => {
|
|
341
|
+
const legacy = createHash("sha256")
|
|
342
|
+
.update("legacy-pass")
|
|
343
|
+
.digest("hex");
|
|
344
|
+
await app.database.set("user", 0, {
|
|
345
|
+
password: legacy,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const client = new TestClient(service());
|
|
349
|
+
const response = await client.call("login/password", [
|
|
350
|
+
"root",
|
|
351
|
+
"legacy-pass",
|
|
352
|
+
]);
|
|
353
|
+
expect(response.error).toBeUndefined();
|
|
354
|
+
const [row] = await app.database.get(
|
|
355
|
+
"user",
|
|
356
|
+
{ id: 0 },
|
|
357
|
+
["password"],
|
|
358
|
+
);
|
|
359
|
+
expect(row?.password?.startsWith("pbkdf2$")).toBe(
|
|
360
|
+
true,
|
|
361
|
+
);
|
|
362
|
+
client.close();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("畸形哈希与空密码一律拒绝", async () => {
|
|
366
|
+
await app.database.set("user", 0, {
|
|
367
|
+
password: "pbkdf2$malformed",
|
|
368
|
+
});
|
|
369
|
+
const malformed = new TestClient(service());
|
|
370
|
+
expect(
|
|
371
|
+
(
|
|
372
|
+
await malformed.call("login/password", [
|
|
373
|
+
"root",
|
|
374
|
+
"x",
|
|
375
|
+
])
|
|
376
|
+
).error,
|
|
377
|
+
).toContain("用户名或密码错误");
|
|
378
|
+
malformed.close();
|
|
379
|
+
|
|
380
|
+
await app.database.set("user", 0, {
|
|
381
|
+
password: "not-a-hash-at-all",
|
|
382
|
+
});
|
|
383
|
+
const alien = new TestClient(service());
|
|
384
|
+
expect(
|
|
385
|
+
(await alien.call("login/password", ["root", "x"]))
|
|
386
|
+
.error,
|
|
387
|
+
).toContain("用户名或密码错误");
|
|
388
|
+
alien.close();
|
|
389
|
+
|
|
390
|
+
await app.database.set("user", 0, { password: "" });
|
|
391
|
+
const empty = new TestClient(service());
|
|
392
|
+
expect(
|
|
393
|
+
(await empty.call("login/password", ["root", "x"]))
|
|
394
|
+
.error,
|
|
395
|
+
).toContain("用户名或密码错误");
|
|
396
|
+
empty.close();
|
|
397
|
+
|
|
398
|
+
// 还原为合法管理员密码(与插件同格式的 PBKDF2 哈希)供后续用例使用
|
|
399
|
+
const salt = randomBytes(16);
|
|
400
|
+
const dk = pbkdf2Sync(
|
|
401
|
+
"admin-pass",
|
|
402
|
+
salt,
|
|
403
|
+
600_000,
|
|
404
|
+
32,
|
|
405
|
+
"sha256",
|
|
406
|
+
);
|
|
407
|
+
await app.database.set("user", 0, {
|
|
408
|
+
password: `pbkdf2$600000$${salt.toString("hex")}$${dk.toString("hex")}`,
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("令牌登录", () => {
|
|
414
|
+
it("有效令牌恢复登录态并刷新最后访问时间", async () => {
|
|
415
|
+
const admin = await loginAdmin();
|
|
416
|
+
const token = admin.client.auth?.token;
|
|
417
|
+
expect(token).toBeTruthy();
|
|
418
|
+
|
|
419
|
+
const client = new TestClient(service());
|
|
420
|
+
const response = await client.call("login/token", [
|
|
421
|
+
0,
|
|
422
|
+
token,
|
|
423
|
+
]);
|
|
424
|
+
expect(response.error).toBeUndefined();
|
|
425
|
+
expect(client.client.auth?.id).toBe(0);
|
|
426
|
+
|
|
427
|
+
const [row] = await app.database.get("token", {
|
|
428
|
+
token: token!,
|
|
429
|
+
});
|
|
430
|
+
expect(row?.lastUsedAt?.valueOf()).toBeGreaterThan(
|
|
431
|
+
row?.createdAt?.valueOf() ?? 0,
|
|
432
|
+
);
|
|
433
|
+
admin.close();
|
|
434
|
+
client.close();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("过期 / 不存在 / 用户缺失的令牌被拒绝", async () => {
|
|
438
|
+
await app.database.create("token", {
|
|
439
|
+
id: 0,
|
|
440
|
+
type: "password",
|
|
441
|
+
token: "expired-token",
|
|
442
|
+
expiredAt: Date.now() - 1000,
|
|
443
|
+
createdAt: new Date(),
|
|
444
|
+
lastUsedAt: new Date(),
|
|
445
|
+
userAgent: "ua",
|
|
446
|
+
address: "addr",
|
|
447
|
+
});
|
|
448
|
+
await app.database.create("token", {
|
|
449
|
+
id: 999,
|
|
450
|
+
type: "password",
|
|
451
|
+
token: "ghost-token",
|
|
452
|
+
expiredAt: Date.now() + Time.hour,
|
|
453
|
+
createdAt: new Date(),
|
|
454
|
+
lastUsedAt: new Date(),
|
|
455
|
+
userAgent: "ua",
|
|
456
|
+
address: "addr",
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const client = new TestClient(service());
|
|
460
|
+
expect(
|
|
461
|
+
(
|
|
462
|
+
await client.call("login/token", [
|
|
463
|
+
0,
|
|
464
|
+
"expired-token",
|
|
465
|
+
])
|
|
466
|
+
).error,
|
|
467
|
+
).toContain("令牌已失效");
|
|
468
|
+
expect(
|
|
469
|
+
(
|
|
470
|
+
await client.call("login/token", [
|
|
471
|
+
0,
|
|
472
|
+
"no-such-token",
|
|
473
|
+
])
|
|
474
|
+
).error,
|
|
475
|
+
).toContain("令牌已失效");
|
|
476
|
+
expect(
|
|
477
|
+
(
|
|
478
|
+
await client.call("login/token", [
|
|
479
|
+
999,
|
|
480
|
+
"ghost-token",
|
|
481
|
+
])
|
|
482
|
+
).error,
|
|
483
|
+
).toContain("用户不存在");
|
|
484
|
+
client.close();
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
describe("平台验证码登录", () => {
|
|
489
|
+
it("账户不存在或已绑定同一账户时拒绝", async () => {
|
|
490
|
+
const client = new TestClient(service());
|
|
491
|
+
expect(
|
|
492
|
+
(
|
|
493
|
+
await client.call("login/platform", [
|
|
494
|
+
"mock",
|
|
495
|
+
"404",
|
|
496
|
+
])
|
|
497
|
+
).error,
|
|
498
|
+
).toContain("找不到此账户");
|
|
499
|
+
|
|
500
|
+
client.client.auth = { id: aliceId } as Auth;
|
|
501
|
+
expect(
|
|
502
|
+
(
|
|
503
|
+
await client.call("login/platform", [
|
|
504
|
+
"mock",
|
|
505
|
+
"111",
|
|
506
|
+
])
|
|
507
|
+
).error,
|
|
508
|
+
).toContain("你已经绑定了此账户");
|
|
509
|
+
client.close();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("未登录客户端经验证码完成登录", async () => {
|
|
513
|
+
const client = new TestClient(service());
|
|
514
|
+
const result = await client.call("login/platform", [
|
|
515
|
+
"mock",
|
|
516
|
+
"111",
|
|
517
|
+
]);
|
|
518
|
+
expect(result.error).toBeUndefined();
|
|
519
|
+
expect(result.value).toMatchObject({
|
|
520
|
+
id: aliceId,
|
|
521
|
+
name: "alice",
|
|
522
|
+
});
|
|
523
|
+
const code = (result.value as { token: string })
|
|
524
|
+
.token;
|
|
525
|
+
expect(code).toMatch(/^\d{6}$/);
|
|
526
|
+
|
|
527
|
+
// 无关消息不消费验证码状态
|
|
528
|
+
const bot = app.mock.client("999", "888");
|
|
529
|
+
await bot.receive("hello-world");
|
|
530
|
+
|
|
531
|
+
// 用户把验证码发给机器人 → 为对应账户签发令牌
|
|
532
|
+
const user = app.mock.client("111");
|
|
533
|
+
await user.receive(code);
|
|
534
|
+
await tick();
|
|
535
|
+
expect(client.client.auth?.id).toBe(aliceId);
|
|
536
|
+
const data = client.lastUserData();
|
|
537
|
+
expect(data?.name).toBe("alice");
|
|
538
|
+
client.close();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("已登录客户端经验证码改绑平台账户", async () => {
|
|
542
|
+
const client = new TestClient(service());
|
|
543
|
+
// 已登录为 alice,把 bob 的平台账号绑到 alice 名下
|
|
544
|
+
client.client.auth = { id: aliceId } as Auth;
|
|
545
|
+
const result = await client.call("login/platform", [
|
|
546
|
+
"mock",
|
|
547
|
+
"222",
|
|
548
|
+
]);
|
|
549
|
+
expect(result.error).toBeUndefined();
|
|
550
|
+
const code = (result.value as { token: string })
|
|
551
|
+
.token;
|
|
552
|
+
|
|
553
|
+
const user = app.mock.client("222");
|
|
554
|
+
await user.receive(code);
|
|
555
|
+
await tick();
|
|
556
|
+
const [binding] = await app.database.get("binding", {
|
|
557
|
+
platform: "mock",
|
|
558
|
+
pid: "222",
|
|
559
|
+
});
|
|
560
|
+
expect(binding?.aid).toBe(aliceId);
|
|
561
|
+
client.close();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("验证码超时后触发状态清理回调", async () => {
|
|
565
|
+
// Schema 要 loginTokenExpire >= 1min,构造后直接改字段绕开校验,
|
|
566
|
+
// 用 50ms 时效快速触发超时回调
|
|
567
|
+
const keep = app.auth.config.loginTokenExpire;
|
|
568
|
+
app.auth.config.loginTokenExpire = 50;
|
|
569
|
+
const client = new TestClient(service());
|
|
570
|
+
const result = await client.call("login/platform", [
|
|
571
|
+
"mock",
|
|
572
|
+
"111",
|
|
573
|
+
]);
|
|
574
|
+
expect(result.error).toBeUndefined();
|
|
575
|
+
// 等待超时回调执行(状态清理与否取决于到期判定,此处验证回调不抛错)
|
|
576
|
+
await new Promise((resolve) =>
|
|
577
|
+
setTimeout(resolve, 150),
|
|
578
|
+
);
|
|
579
|
+
client.close();
|
|
580
|
+
app.auth.config.loginTokenExpire = keep;
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
describe("权限拦截(console/intercept)", () => {
|
|
585
|
+
it("按登录态与权限等级拦截事件", async () => {
|
|
586
|
+
app.console.addListener(
|
|
587
|
+
"test/admin-only",
|
|
588
|
+
() => "secret",
|
|
589
|
+
{
|
|
590
|
+
authority: 4,
|
|
591
|
+
},
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
// 未登录:拦截
|
|
595
|
+
const anonymous = new TestClient(service());
|
|
596
|
+
expect(
|
|
597
|
+
(await anonymous.call("test/admin-only", [])).error,
|
|
598
|
+
).toBe("unauthorized");
|
|
599
|
+
anonymous.close();
|
|
600
|
+
|
|
601
|
+
// 权限不足:拦截
|
|
602
|
+
const low = new TestClient(service());
|
|
603
|
+
low.client.auth = {
|
|
604
|
+
id: bobId,
|
|
605
|
+
authority: 1,
|
|
606
|
+
expiredAt: Date.now() + Time.hour,
|
|
607
|
+
token: "t",
|
|
608
|
+
} as Auth;
|
|
609
|
+
expect(
|
|
610
|
+
(await low.call("test/admin-only", [])).error,
|
|
611
|
+
).toBe("unauthorized");
|
|
612
|
+
low.close();
|
|
613
|
+
|
|
614
|
+
// 令牌过期:拦截
|
|
615
|
+
const expired = new TestClient(service());
|
|
616
|
+
expired.client.auth = {
|
|
617
|
+
id: 0,
|
|
618
|
+
authority: 5,
|
|
619
|
+
expiredAt: Date.now() - 1,
|
|
620
|
+
token: "t",
|
|
621
|
+
} as Auth;
|
|
622
|
+
expect(
|
|
623
|
+
(await expired.call("test/admin-only", [])).error,
|
|
624
|
+
).toBe("unauthorized");
|
|
625
|
+
expired.close();
|
|
626
|
+
|
|
627
|
+
// 管理员:放行
|
|
628
|
+
const admin = await loginAdmin();
|
|
629
|
+
expect(
|
|
630
|
+
(await admin.call("test/admin-only", [])).value,
|
|
631
|
+
).toBe("secret");
|
|
632
|
+
admin.close();
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
describe("用户管理事件", () => {
|
|
637
|
+
it("user/delete-token 删除指定会话", async () => {
|
|
638
|
+
const anonymous = new TestClient(service());
|
|
639
|
+
expect(
|
|
640
|
+
(await anonymous.call("user/delete-token", [1]))
|
|
641
|
+
.error,
|
|
642
|
+
).toContain("请先登录");
|
|
643
|
+
anonymous.close();
|
|
644
|
+
|
|
645
|
+
const admin = await loginAdmin();
|
|
646
|
+
const [row] = await app.database.get("token", {
|
|
647
|
+
token: admin.client.auth!.token,
|
|
648
|
+
});
|
|
649
|
+
const bad = await admin.call(
|
|
650
|
+
"user/delete-token",
|
|
651
|
+
[99999],
|
|
652
|
+
);
|
|
653
|
+
expect(bad.error).toContain("令牌不存在");
|
|
654
|
+
|
|
655
|
+
const ok = await admin.call("user/delete-token", [
|
|
656
|
+
row?.inc,
|
|
657
|
+
]);
|
|
658
|
+
expect(ok.error).toBeUndefined();
|
|
659
|
+
const [removed] = await app.database.get("token", {
|
|
660
|
+
inc: row!.inc,
|
|
661
|
+
});
|
|
662
|
+
expect(removed).toBeUndefined();
|
|
663
|
+
admin.close();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("user/logout 删除当前令牌并重发登录态", async () => {
|
|
667
|
+
const admin = await loginAdmin();
|
|
668
|
+
const token = admin.client.auth?.token;
|
|
669
|
+
const response = await admin.call("user/logout", []);
|
|
670
|
+
expect(response.error).toBeUndefined();
|
|
671
|
+
// 令牌已被删除,其它设备无法再用它续期(loginAdmin 成功后令牌必存在)
|
|
672
|
+
const [row] = await app.database.get("token", {
|
|
673
|
+
token: token!,
|
|
674
|
+
});
|
|
675
|
+
expect(row).toBeUndefined();
|
|
676
|
+
// 移植偏差说明:上游 logout 以 setAuth(this, null) 显式清空登录态,
|
|
677
|
+
// 本仓改为传 undefined,命中 setAuth 的默认参数(沿用 client.auth),
|
|
678
|
+
// 故当前行为是重发 user 数据而非下发 null;此处按实际行为断言
|
|
679
|
+
expect(admin.client.auth?.id).toBe(0);
|
|
680
|
+
const data = admin.lastUserData();
|
|
681
|
+
expect(data?.name).toBe("root");
|
|
682
|
+
admin.close();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("setAuth 对匿名客户端下发 null(登出数据通道)", async () => {
|
|
686
|
+
const anonymous = new TestClient(service());
|
|
687
|
+
await app.auth.setAuth(anonymous.client);
|
|
688
|
+
await tick();
|
|
689
|
+
expect(anonymous.lastUserData()).toBeNull();
|
|
690
|
+
anonymous.close();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("user/update 修改资料(密码加哈希、配置透传)", async () => {
|
|
694
|
+
const anonymous = new TestClient(service());
|
|
695
|
+
expect(
|
|
696
|
+
(
|
|
697
|
+
await anonymous.call("user/update", [
|
|
698
|
+
{ name: "x" },
|
|
699
|
+
])
|
|
700
|
+
).error,
|
|
701
|
+
).toContain("请先登录");
|
|
702
|
+
anonymous.close();
|
|
703
|
+
|
|
704
|
+
const admin = await loginAdmin();
|
|
705
|
+
const response = await admin.call("user/update", [
|
|
706
|
+
{
|
|
707
|
+
name: "renamed",
|
|
708
|
+
password: "new-pass",
|
|
709
|
+
config: { theme: "dark" },
|
|
710
|
+
},
|
|
711
|
+
]);
|
|
712
|
+
expect(response.error).toBeUndefined();
|
|
713
|
+
// passive 更新:本地登录态同步但不推送数据
|
|
714
|
+
expect(admin.client.auth?.name).toBe("renamed");
|
|
715
|
+
const [row] = await app.database.get("user", {
|
|
716
|
+
id: 0,
|
|
717
|
+
});
|
|
718
|
+
expect(row?.name).toBe("renamed");
|
|
719
|
+
expect(row?.password?.startsWith("pbkdf2$")).toBe(
|
|
720
|
+
true,
|
|
721
|
+
);
|
|
722
|
+
expect(row?.config).toEqual({ theme: "dark" });
|
|
723
|
+
admin.close();
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("user/unbind 解绑平台账号的三种分支", async () => {
|
|
727
|
+
const anonymous = new TestClient(service());
|
|
728
|
+
expect(
|
|
729
|
+
(
|
|
730
|
+
await anonymous.call("user/unbind", [
|
|
731
|
+
"mock",
|
|
732
|
+
"111",
|
|
733
|
+
])
|
|
734
|
+
).error,
|
|
735
|
+
).toContain("请先登录");
|
|
736
|
+
anonymous.close();
|
|
737
|
+
|
|
738
|
+
// 以 alice 身份登录
|
|
739
|
+
const alice = new TestClient(service());
|
|
740
|
+
const grant = await alice.call("login/platform", [
|
|
741
|
+
"mock",
|
|
742
|
+
"111",
|
|
743
|
+
]);
|
|
744
|
+
const code = (grant.value as { token: string }).token;
|
|
745
|
+
await app.mock.client("111").receive(code);
|
|
746
|
+
await tick();
|
|
747
|
+
expect(alice.client.auth?.id).toBe(aliceId);
|
|
748
|
+
|
|
749
|
+
// 绑定不存在
|
|
750
|
+
expect(
|
|
751
|
+
(await alice.call("user/unbind", ["mock", "000"]))
|
|
752
|
+
.error,
|
|
753
|
+
).toContain("绑定不存在");
|
|
754
|
+
|
|
755
|
+
// 仅剩一个自绑定(主账号):拒绝解绑
|
|
756
|
+
expect(
|
|
757
|
+
(await alice.call("user/unbind", ["mock", "111"]))
|
|
758
|
+
.error,
|
|
759
|
+
).toContain("无法解除绑定");
|
|
760
|
+
|
|
761
|
+
// 追加第二个自绑定后可解绑
|
|
762
|
+
await app.database.create("binding", {
|
|
763
|
+
aid: aliceId,
|
|
764
|
+
bid: aliceId,
|
|
765
|
+
platform: "mock",
|
|
766
|
+
pid: "333",
|
|
767
|
+
});
|
|
768
|
+
expect(
|
|
769
|
+
(await alice.call("user/unbind", ["mock", "333"]))
|
|
770
|
+
.error,
|
|
771
|
+
).toBeUndefined();
|
|
772
|
+
const [gone] = await app.database.get("binding", {
|
|
773
|
+
platform: "mock",
|
|
774
|
+
pid: "333",
|
|
775
|
+
});
|
|
776
|
+
expect(gone).toBeUndefined();
|
|
777
|
+
|
|
778
|
+
// 绑到他人名下(aid !== bid):解绑时改回其主账号
|
|
779
|
+
await app.database.create("binding", {
|
|
780
|
+
aid: aliceId,
|
|
781
|
+
bid: bobId,
|
|
782
|
+
platform: "mock",
|
|
783
|
+
pid: "444",
|
|
784
|
+
});
|
|
785
|
+
expect(
|
|
786
|
+
(await alice.call("user/unbind", ["mock", "444"]))
|
|
787
|
+
.error,
|
|
788
|
+
).toBeUndefined();
|
|
789
|
+
const [moved] = await app.database.get("binding", {
|
|
790
|
+
platform: "mock",
|
|
791
|
+
pid: "444",
|
|
792
|
+
});
|
|
793
|
+
expect(moved?.aid).toBe(bobId);
|
|
794
|
+
alice.close();
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
});
|